Pith - kiln
kiln/app/src/main/java/com/vgmlr/kiln/KilnCalendar.kt [23.5 kb]
Modified: 18:59:25 154 026 (20 Aug 026)
3 Days Ago
package com.vgmlr.kiln

import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
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.ArrowBack
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.BarChart
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.outlined.History
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.Share
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.draw.scale
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import java.time.LocalDate
import java.time.YearMonth
import java.time.format.TextStyle
import java.time.temporal.ChronoUnit
import java.util.Locale
import kotlin.math.roundToInt
import kotlinx.coroutines.launch
import androidx.compose.material.icons.automirrored.outlined.Login
import androidx.compose.material.icons.automirrored.outlined.Logout
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts

private const val PAGE_COUNT = 1000
private val EPOCH_MONTH = YearMonth.of(2000, 1)
private val ROW_HEIGHT = 44.dp
private val CELL_GAP = 0.dp
private val DATA_DOT = 5.dp
private val DATA_DOT_PAD = 6.dp
private val GRID_TOP_GAP = 0.dp
private val NAV_BUTTON = 32.dp
private val NAV_ICON = 20.dp

private val TodayBorder = Color(0xFFFFFFFF)
private val TodayBorderWidth = 1.dp
private val TodayBorderShape = RoundedCornerShape(10.dp)
private val SelectedBorder = Color(0xFFFFFFFF)
private val SelectedBorderWidth = 2.dp

private val CARD_PAD_H = 18.dp
private val CARD_PAD_V = 14.dp
private val STAT_CARD_INSET_H = 9.dp
private val SECTION_GAP = 12.dp
private val HEADER_GAP = 0.dp
private val STAT_GAP = 5.dp
private val STAT_CARD_SHAPE = RoundedCornerShape(12.dp)
private val STAT_CARD_PAD_H = 16.dp
private val STAT_CARD_PAD_V = 12.dp
private val STAT_TRACKING = 0.8.sp
private val STAT_ICON = 13.dp
private val STAT_ICON_GAP = 7.dp
private val STAT_ICON_NUDGE = (-1.5).dp

private val DATE_NUM = 34.sp
private val META_GAP = 10.dp
private val RULE_H = 30.dp

internal val TopBarBg = Color(0xFF486860)
internal val TopBarFg = Color.White
internal val MenuBg = Color(0xFF272A30)
private val CardBg = Color(0xFF16181B)
private val StatCardBg = Color(0xFF1F2226) 

private fun pageToMonth(page: Int): YearMonth =
    EPOCH_MONTH.plusMonths((page - PAGE_COUNT / 2).toLong())
        .plusMonths(YearMonth.now().let {
            ChronoUnit.MONTHS.between(EPOCH_MONTH, it)
        })

private val MOOD_FACE_SIZE = 74.dp
private val MOOD_FACE_BOX = MOOD_FACE_SIZE + 0.dp

private sealed interface Screen {
    data object Home : Screen
    data object HomeSettings : Screen
    data object Calendar : Screen
    data object AccountSettings : Screen
    data object AccountCharts : Screen
}

private val Screen.parent: Screen?
    get() = when (this) {
        Screen.Home -> null
        Screen.HomeSettings, Screen.Calendar -> Screen.Home
        Screen.AccountSettings, Screen.AccountCharts -> Screen.Calendar
    }

@Composable
fun KilnApp(vm: KilnViewModel) {
    var screen by remember { mutableStateOf<Screen>(Screen.Home) }
    val summaries by vm.summaries.collectAsState()
    val account by vm.activeKilnAccount.collectAsState()
    val accounts by vm.accounts.collectAsState()
    val defaultId by vm.defaultAccountId.collectAsState()
    val loadDefault by vm.loadDefaultOnStart.collectAsState()

    var autoLoaded by remember { mutableStateOf(false) }
    LaunchedEffect(loadDefault, defaultId, accounts) {
        if (autoLoaded || !loadDefault) return@LaunchedEffect
        val target = accounts.firstOrNull { it.id == defaultId } ?: return@LaunchedEffect
        autoLoaded = true
        vm.open(target)
        screen = Screen.Calendar
    }

    val parent = screen.parent
    BackHandler(enabled = parent != null) { parent?.let { screen = it } }

    when (screen) {
        Screen.Home -> HomeScreen(
            summaries,
            onOpen = { vm.open(it); screen = Screen.Calendar },
            onAdd = vm::addAccount,
            onSettings = { screen = Screen.HomeSettings },
            onImport = vm::importFrom
        )
        Screen.HomeSettings -> KilnHomeSettings(vm, onBack = { screen = Screen.Home })
        Screen.Calendar -> MainScreen(
            vm,
            accountName = account?.name.orEmpty(),
            hair = KilnHair.of(account?.hair),
            onBack = { screen = Screen.Home },
            onSettings = { screen = Screen.AccountSettings },
            onCharts = { screen = Screen.AccountCharts }
        )
        Screen.AccountSettings -> KilnAccountSettings(
            vm,
            onBack = { screen = Screen.Calendar },
            onDeleted = { screen = Screen.Home }
        )
        Screen.AccountCharts -> KilnAccountCharts(vm, onBack = { screen = Screen.Calendar })
    }
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun KilnTopBar(
    title: String,
    onSettings: () -> Unit,
    onBack: (() -> Unit)? = null,
    onVersion: (() -> Unit)? = null,
    onCharts: (() -> Unit)? = null,
    onImport: (() -> Unit)? = null,
    onExport: (() -> Unit)? = null,
    action: @Composable () -> Unit = {}
) {
    var menuExpanded by remember { mutableStateOf(false) }
    TopAppBar(
        title = { },
        colors = TopAppBarDefaults.topAppBarColors(containerColor = TopBarBg),
        navigationIcon = {
            if (onBack != null) IconButton(onClick = onBack) {
                Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back", tint = TopBarFg)
            }
        },
        actions = {
            Row(
                modifier = Modifier.fillMaxWidth().height(56.dp).padding(start = 16.dp, end = 4.dp),
                verticalAlignment = Alignment.CenterVertically
            ) {
                Text(
                    title,
                    Modifier.weight(1f),
                    color = TopBarFg,
                    fontSize = 18.sp,
                    textAlign = TextAlign.End,
                    maxLines = 1,
                    overflow = TextOverflow.Ellipsis
                )
                Spacer(Modifier.width(10.dp))
                action()
                Box {
                    IconButton(onClick = { menuExpanded = true }) {
                        Icon(Icons.Default.MoreVert, null, tint = TopBarFg)
                    }
                    DropdownMenu(
                        expanded = menuExpanded,
                        onDismissRequest = { menuExpanded = false },
                        modifier = Modifier.background(MenuBg).padding(horizontal = 6.dp)
                    ) {
                        if (onImport != null) DropdownMenuItem(
                            text = { Text("Import", color = TopBarFg) },
                            onClick = { menuExpanded = false; onImport() },
                            leadingIcon = {
                                Icon(Icons.AutoMirrored.Outlined.Login, null, tint = TopBarFg)
                            }
                        )
                        if (onExport != null) DropdownMenuItem(
                            text = { Text("Export", color = TopBarFg) },
                            onClick = { menuExpanded = false; onExport() },
                            leadingIcon = {
                                Icon(Icons.AutoMirrored.Outlined.Logout, null, tint = TopBarFg)
                            }
                        )
                        if (onCharts != null) DropdownMenuItem(
                            text = { Text("Charts", color = TopBarFg) },
                            onClick = { menuExpanded = false; onCharts() },
                            leadingIcon = { Icon(Icons.Filled.BarChart, null, tint = TopBarFg) }
                        )
                        DropdownMenuItem(
                            text = { Text("Settings", color = TopBarFg) },
                            onClick = { menuExpanded = false; onSettings() },
                            leadingIcon = { Icon(Icons.Outlined.Settings, null, tint = TopBarFg) }
                        )
                        if (onVersion != null) DropdownMenuItem(
                            text = { Text("Version", color = TopBarFg) },
                            onClick = { menuExpanded = false; onVersion() },
                            leadingIcon = { Icon(Icons.Outlined.History, null, tint = TopBarFg) }
                        )
                    }
                }
            }
        }
    )
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainScreen(
    vm: KilnViewModel,
    accountName: String,
    hair: KilnHair,
    onBack: () -> Unit,
    onSettings: () -> Unit,
    onCharts: () -> Unit
) {
    val records by vm.records.collectAsState()
    val buttons by vm.buttons.collectAsState()
    val selected by vm.selectedDate.collectAsState()
    val record by vm.selectedRecord.collectAsState()
    val today = remember { LocalDate.now() }

    val exporter = rememberLauncherForActivityResult(
        ActivityResultContracts.CreateDocument(KilnTransfer.MIME)
    ) { uri -> uri?.let(vm::exportActive) }

    val roles = remember(buttons) { KilnButtonSet(buttons) }
    val starts = remember(records, roles) { KilnCyclePredictor.periodStarts(records, roles) }
    val cycle = remember(starts) { KilnCyclePredictor.avgCycleLength(starts) }

    val hueOf: (LocalDate) -> Float = remember(records, roles, starts, cycle) {
        KilnGradientPalette.lookup(records, roles, starts, cycle)
    }

    val faces = remember(hair) { KilnFaces.faces(hair) }

    val dataDays = remember(records) {
        records.filter { it.hasData }.mapTo(HashSet()) { LocalDate.ofEpochDay(it.epochDay) }
    }

    val pager = rememberPagerState(initialPage = PAGE_COUNT / 2) { PAGE_COUNT }
    val scope = rememberCoroutineScope()

    fun selectIn(month: YearMonth) {
        val day = vm.selectedDate.value.dayOfMonth
        vm.select(month.atDay(minOf(day, month.lengthOfMonth())))
    }

    val preAvg = remember(hueOf) { KilnDayStats.preMenstrualAvg(starts, cycle, hueOf) }
    val stats = remember(selected, today, roles, hueOf, preAvg) {
        KilnDayStats.of(selected, today, records, roles, starts, cycle, hueOf, preAvg)
    }

    fun goMonth(delta: Int) {
        val page = (pager.currentPage + delta).coerceIn(0, PAGE_COUNT - 1)
        selectIn(pageToMonth(page))
        scope.launch { pager.animateScrollToPage(page) }
    }

    LaunchedEffect(pager) {
        snapshotFlow { pager.settledPage }.collect { selectIn(pageToMonth(it)) }
    }

    Scaffold(containerColor = CardBg, topBar = {
        KilnTopBar(
            accountName,
            onSettings = onSettings,
            onBack = onBack,
            onCharts = onCharts,
            onExport = { exporter.launch(KilnTransfer.fileName()) },
            action = {
                IconButton(onClick = { }, enabled = false) {
                    Icon(Icons.Outlined.Share, "Share", tint = TopBarFg.copy(alpha = 0.3f))
                }
            }
        )
    }) { pad ->
        Column(Modifier.fillMaxSize().background(CardBg).padding(pad)) {
            HorizontalPager(state = pager) { page ->
                MonthGrid(
                    month = pageToMonth(page),
                    selected = selected,
                    hueOf = hueOf,
                    dataDays = dataDays,
                    onSelect = vm::select
                )
            }
            DayDetail(
                selected, today, record, stats, faces, buttons, vm::edit,
                onToggle = vm::toggle,
                onAddButton = onSettings,
                onPrevMonth = { goMonth(-1) },
                onNextMonth = { goMonth(1) },
                onCharts = onCharts,
                modifier = Modifier.weight(1f)
            )
        }
    }
}

@Composable
private fun MonthGrid(
    month: YearMonth,
    selected: LocalDate,
    hueOf: (LocalDate) -> Float,
    dataDays: Set<LocalDate>,
    onSelect: (LocalDate) -> Unit
) {
    val today = LocalDate.now()
    val firstOffset = month.atDay(1).dayOfWeek.value % 7
    val first = month.atDay(1).minusDays(firstOffset.toLong())
    val cells = (0 until 42L).map(first::plusDays)
    val edges = remember(month, hueOf) {
        (-1L..42L).map { hueOf(first.plusDays(it)) }
            .zipWithNext { a, b -> (a + b) / 2f }
    }
    Column(Modifier.fillMaxWidth()) {
        Spacer(Modifier.height(GRID_TOP_GAP))
        cells.chunked(7).forEachIndexed { row, week ->
            Row(Modifier.fillMaxWidth().height(ROW_HEIGHT)) {
                week.forEachIndexed { col, date ->
                    val i = row * 7 + col
                    DayCell(date, YearMonth.from(date) == month, selected, today,
                        edges[i], edges[i + 1], date in dataDays, onSelect, Modifier.weight(1f))
                }
            }
        }
    }
}

@Composable
private fun DayCell(
    date: LocalDate,
    inMonth: Boolean,
    selected: LocalDate,
    today: LocalDate,
    hueStart: Float,
    hueEnd: Float,
    hasData: Boolean,
    onSelect: (LocalDate) -> Unit,
    modifier: Modifier
) {
    val alpha = if (inMonth) 1f else 0.35f
    val strip = Brush.horizontalGradient(
        listOf(KilnGradientPalette.colorFor(hueStart), KilnGradientPalette.colorFor(hueEnd))
    )
    val border = when (date) {
        selected -> Modifier.border(SelectedBorderWidth, SelectedBorder, TodayBorderShape)
        today -> Modifier.border(TodayBorderWidth, TodayBorder, TodayBorderShape)
        else -> Modifier
    }
    Box(
        modifier.fillMaxHeight()
            .padding(CELL_GAP)
            .background(strip, alpha = alpha)
            .then(border)
            .clickable { onSelect(date) },
        contentAlignment = Alignment.Center
    ) {
        Text("${date.dayOfMonth}", fontSize = 15.sp,
            color = Color(0xFF111111).copy(alpha = if (inMonth) 0.9f else 0.6f),
            fontWeight = if (date == today || date == selected) FontWeight.Bold
            else FontWeight.Medium)
        if (hasData) Box(
            Modifier.align(Alignment.BottomEnd)
                .padding(DATA_DOT_PAD)
                .size(DATA_DOT)
                .background(
                    Color.Black.copy(alpha = if (inMonth) 0.8f else 0.35f),
                    CircleShape
                )
        )
    }
}

@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun DayDetail(
    date: LocalDate,
    today: LocalDate,
    record: KilnDayRecord?,
    stats: KilnDayStats,
    faces: List<Int>,
    buttons: List<KilnButton>,
    edit: ((KilnDayRecord) -> KilnDayRecord) -> Unit,
    onToggle: (Long) -> Unit,
    onAddButton: () -> Unit,
    onPrevMonth: () -> Unit,
    onNextMonth: () -> Unit,
    onCharts: () -> Unit,
    modifier: Modifier = Modifier
) {
    Surface(
        modifier.fillMaxWidth(),
        shape = RectangleShape,
        color = CardBg
    ) {
        Column(Modifier.fillMaxSize().padding(vertical = CARD_PAD_V)) {
            DateHeader(
                date, onPrevMonth, onNextMonth,
                Modifier.padding(horizontal = CARD_PAD_H)
            )
            Spacer(Modifier.height(HEADER_GAP))
            Column(
                Modifier.fillMaxWidth().weight(1f),
                verticalArrangement = Arrangement.spacedBy(SECTION_GAP, Alignment.CenterVertically)
            ) {
                MoodRow(record?.mood, faces, Modifier.padding(horizontal = CARD_PAD_H)) { i ->
                    edit { it.copy(mood = if (it.mood == i) null else i) }
                }
                ChipPager(buttons, record?.checked.orEmpty(), onToggle, onAddButton)
            }
            Spacer(Modifier.height(SECTION_GAP))
            StatBlock(today, stats, onCharts, Modifier.padding(horizontal = STAT_CARD_INSET_H))
        }
    }
}

@Composable
private fun DateHeader(
    date: LocalDate,
    onPrev: () -> Unit,
    onNext: () -> Unit,
    modifier: Modifier = Modifier
) {
    val weekday = date.dayOfWeek.getDisplayName(TextStyle.FULL, Locale.getDefault())
        .uppercase(Locale.getDefault())
    val month = date.month.getDisplayName(TextStyle.FULL, Locale.getDefault())
        .uppercase(Locale.getDefault())

    Row(modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
        NavButton(Icons.AutoMirrored.Filled.KeyboardArrowLeft, "Previous month", onPrev)
        Row(
            Modifier.weight(1f),
            horizontalArrangement = Arrangement.Center,
            verticalAlignment = Alignment.CenterVertically
        ) {
            Text(
                "${date.dayOfMonth}",
                fontSize = DATE_NUM,
                lineHeight = DATE_NUM,
                letterSpacing = (-1).sp,
                fontWeight = FontWeight.Light,
                color = Color.White.copy(alpha = 0.9f)
            )
            Spacer(Modifier.width(META_GAP))
            Box(
                Modifier.height(RULE_H).width(1.dp)
                    .background(Color.White.copy(alpha = 0.12f))
            )
            Spacer(Modifier.width(META_GAP))
            Column(verticalArrangement = Arrangement.spacedBy(1.dp)) {
                Text(
                    weekday,
                    fontSize = 11.sp,
                    lineHeight = 11.sp,
                    letterSpacing = 2.sp,
                    fontWeight = FontWeight.Medium,
                    color = Color.White.copy(alpha = 0.45f)
                )
                Text(
                    "$month ${date.year.toString().takeLast(3)}",
                    fontSize = 13.sp,
                    lineHeight = 13.sp,
                    letterSpacing = 1.sp,
                    fontWeight = FontWeight.SemiBold,
                    color = Color.White.copy(alpha = 0.88f)
                )
            }
        }
        NavButton(Icons.AutoMirrored.Filled.KeyboardArrowRight, "Next month", onNext)
    }
}

@Composable
private fun StatBlock(
    today: LocalDate,
    stats: KilnDayStats,
    onCharts: () -> Unit,
    modifier: Modifier = Modifier
) {
    Column(
        modifier.fillMaxWidth()
            .clip(STAT_CARD_SHAPE)
            .background(StatCardBg)
            .padding(horizontal = STAT_CARD_PAD_H, vertical = STAT_CARD_PAD_V),
        verticalArrangement = Arrangement.spacedBy(STAT_GAP)
    ) {
        StatRow("NEXT PRE-MENSTRUATION",
            daysText(stats.daysToPreMenstrual) + dateText(today, stats.daysToPreMenstrual)
        )
        StatRow("NEXT MENSTRUATION",
            daysText(stats.daysToPeriod) + dateText(today, stats.daysToPeriod)
        )
        StatRow("AVERAGE CYCLE", avgText(stats.intermissionAvg), onCharts)
        StatRow("AVERAGE PRE-MENSTRUATION", avgText(stats.preMenstrualAvg), onCharts)
        StatRow("AVERAGE MENSTRUATION", avgText(stats.menstruationAvg), onCharts)
        StatRow("MOOD RATIO", moodText(stats))
    }
}

@Composable
private fun StatRow(label: String, value: String, onOpen: (() -> Unit)? = null) {
    Row(
        Modifier.fillMaxWidth().then(
            if (onOpen == null) Modifier else Modifier.clickable(
                interactionSource = remember { MutableInteractionSource() },
                indication = null,
                onClick = onOpen
            )
        ),
        horizontalArrangement = Arrangement.SpaceBetween,
        verticalAlignment = Alignment.CenterVertically
    ) {
        Row(verticalAlignment = Alignment.CenterVertically) {
            Text(
                label,
                fontSize = 11.sp,
                letterSpacing = 1.4.sp,
                fontWeight = FontWeight.Medium,
                color = Color.White.copy(alpha = 0.40f)
            )
            if (onOpen != null) {
                Spacer(Modifier.width(STAT_ICON_GAP))
                Icon(
                    Icons.Filled.BarChart,
                    "Open Charts",
                    Modifier.size(STAT_ICON).offset(y = STAT_ICON_NUDGE),
                    tint = Color.White.copy(alpha = 0.45f)
                )
            }
        }
        Text(
            value,
            fontSize = 13.sp,
            letterSpacing = STAT_TRACKING,
            fontWeight = FontWeight.SemiBold,
            color = Color.White.copy(alpha = 0.9f)
        )
    }
}

private fun daysText(days: Long?): String = when (days) {
    null -> "—"
    0L -> "TODAY"
    1L -> "1 DAY"
    else -> "$days DAYS"
}

private fun avgText(days: Float?): String {
    if (days == null) return "—"
    val r = (days * 10f).roundToInt() / 10f
    val n = if (r % 1f == 0f) r.toInt().toString() else r.toString()
    return "$n ${if (r == 1f) "DAY" else "DAYS"}"
}

private fun dateText(date: LocalDate, days: Long?): String {
    val target = date.plusDays(days ?: return "")
    val month = target.month
        .getDisplayName(TextStyle.SHORT, Locale.getDefault())
        .uppercase(Locale.getDefault())
        .take(3)
    return " (${target.dayOfMonth} $month)"
}

private fun moodText(stats: KilnDayStats): String {
    val top = stats.topMood ?: return "—"
    return moodPart(stats.topMoodCount, stats.moodDays, top)
}

private fun moodPart(count: Int, days: Int, mood: Int): String {
    val pct = (count * 100f / days).roundToInt()
    return "$pct% ${KilnFaces.LABELS[mood].uppercase(Locale.getDefault())}"
}

@Composable
private fun NavButton(icon: ImageVector, desc: String, onClick: () -> Unit) {
    IconButton(
        onClick,
        Modifier.size(NAV_BUTTON).clip(CircleShape).background(Color.White.copy(alpha = 0.0f))
    ) {
        Icon(icon, desc, Modifier.size(NAV_ICON), tint = Color.White.copy(alpha = 0.55f))
    }
}

@Composable
private fun MoodRow(
    mood: Int?,
    faces: List<Int>,
    modifier: Modifier = Modifier,
    onPick: (Int) -> Unit
) {
    Row(modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
        faces.forEachIndexed { i, faceRes ->
            val k by animateFloatAsState(if (mood == i) 1f else 0f, label = "mood$i")
            Box(
                Modifier.size(MOOD_FACE_BOX)
                    .scale(0.80f + 0.20f * k)
                    .clip(CircleShape)
                    .background(Color.White.copy(alpha = 0.10f * k))
                    .border(1.5.dp, Color.White.copy(alpha = 0.2f * k), CircleShape)
                    .clickable { onPick(i) },
                contentAlignment = Alignment.Center
            ) {
                Image(
                    painterResource(faceRes),
                    KilnFaces.LABELS[i],
                    Modifier.size(MOOD_FACE_SIZE),
                    alpha = 0.80f + 0.20f * k
                )
            }
        }
    }
}
Updates
Kerf - Android 157.026
Kiln - Android 157.026
Wedge - Android 156.026
Whittle - Linux 155.026
Grit - Firefox/Chrome 154.026

Menu
Calendar
Project Tin (024/029)
Miter
RSS Feed
User Avatar
@vgmlr
=SUM(parts)
0.00369
256,914 (+168)