Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .beads/issues.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
{"_type":"issue","id":"fedo-android-example-its.3","title":"Models list screen","description":"Newest models first, with loading, error and empty states and pull-to-refresh. A failed refresh must keep already-loaded data and say so inline (do not wipe the list, do not fail silently). Only one load may run at a time: a new load cancels/supersedes the in-flight one so the initial load, refresh and retry cannot race. The error state offers Retry; the empty state offers Refresh. Row shows short name, provider display name, relative release date, context label and input price per 1M.","acceptance_criteria":"AC-1: the list shows models newest first. AC-2: a first load that fails shows an error state with Retry. AC-3: a refresh that fails while models are on screen keeps the list and shows an inline notice. AC-4: an empty catalogue shows an empty state with Refresh. AC-5: only one load runs at a time; a refresh started while a load is in flight joins it and shares its result instead of starting a second fetch.","status":"closed","priority":1,"issue_type":"feature","assignee":"mabd","owner":"mabd.agent@gmail.com","created_at":"2026-09-21T08:20:49Z","created_by":"mabd","updated_at":"2026-09-21T11:33:37Z","started_at":"2026-09-21T10:50:32Z","closed_at":"2026-09-21T11:12:24Z","close_reason":"List screen with loading, error, empty, refreshing and inline-refresh-error states; AC-1..AC-5 each have a named test; verified on device against the live catalogue. Needed a material3 1.5.0-alpha28 pin for expressive (decisions/0003).","labels":["app"],"dependencies":[{"issue_id":"fedo-android-example-its.3","depends_on_id":"fedo-android-example-its","type":"parent-child","created_at":"2026-09-21T11:20:49Z","created_by":"mabd","metadata":"{}"},{"issue_id":"fedo-android-example-its.3","depends_on_id":"fedo-android-example-its.1","type":"blocks","created_at":"2026-09-21T11:20:49Z","created_by":"mabd","metadata":"{}"}],"dependency_count":1,"dependent_count":3,"comment_count":0}
{"_type":"issue","id":"fedo-android-example-its.1","title":"OpenRouter client, model type and formatters","description":"Model type decoding the subset above, tolerant of null/missing optional fields. Derived values: release date, provider display name (name prefix before \": \" else id prefix with \"~\" stripped), a stable lowercase providerID grouping key, short name, price per 1M tokens as a display string, context label.\n\nFormatting rules (from the iOS example): \"0.00000096\" -\u003e \"$0.96\", \"0.000000075\" -\u003e \"$0.075\", \"0.000015\" -\u003e \"$15\", \"0\" -\u003e \"Free\", \"-1\"/unparsable -\u003e \"Variable\", and anything above 0 but below $0.01 per 1M -\u003e \"\u003c$0.01\" (never \"$0\"). Context: 1_048_576 -\u003e \"1M\", 200_000 -\u003e \"200K\", 131_072 -\u003e \"131K\", 32_768 -\u003e \"32K\", 512 -\u003e \"512\".\n\nFetch function returns models sorted newest first and throws on non-2xx. Keep decoding/formatting as pure functions so they can be unit tested without the network.","acceptance_criteria":"Fetching decodes all live models; formatter and grouping functions are pure and callable from tests.","status":"closed","priority":1,"issue_type":"task","owner":"mabd.agent@gmail.com","created_at":"2026-09-21T08:20:46Z","created_by":"mabd","updated_at":"2026-09-21T10:31:54Z","closed_at":"2026-09-21T10:31:54Z","close_reason":"Data layer landed: AiModel + Price + pure formatters + OpenRouterDataSource.getModels(). Verified against the live endpoint (446 models decode, newest first). Broader test coverage stays with its.2, the payload fixture with its.8.","labels":["app"],"dependencies":[{"issue_id":"fedo-android-example-its.1","depends_on_id":"fedo-android-example-8nq.2","type":"blocks","created_at":"2026-09-21T11:20:46Z","created_by":"mabd","metadata":"{}"},{"issue_id":"fedo-android-example-its.1","depends_on_id":"fedo-android-example-its","type":"parent-child","created_at":"2026-09-21T11:20:45Z","created_by":"mabd","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0}
{"_type":"issue","id":"fedo-android-example-its","title":"ModelPulse core app","description":"Browse the newest AI models from the public OpenRouter API: GET https://openrouter.ai/api/v1/models (no key, ~445 models).\n\nEach data[] entry has: id (\"anthropic/claude-opus-5\", sometimes \"~\" prefixed), name (often \"Provider: Model\"), created (unix seconds), description, context_length (nullable), pricing.prompt / pricing.completion (USD per token, as STRINGS; \"0\" = free, \"-1\" = variable), architecture.input_modalities ([String]).\n\nKeep the app small and readable - it is documentation for SDK integrators. Put logic (decoding, formatting, filtering) in plain testable functions, not inside composables.","status":"closed","priority":1,"issue_type":"epic","owner":"mabd.agent@gmail.com","created_at":"2026-09-21T08:20:45Z","created_by":"mabd","updated_at":"2026-09-23T05:43:07Z","closed_at":"2026-09-23T05:43:07Z","close_reason":"All children closed: catalogue client, formatters, list, search and provider filter, detail, navigation, fixture, snackbar, string resources, theme pass. Full gate green, three destinations smoke-tested on the tablet","labels":["app"],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"fedo-android-example-f9g.23","title":"Pre-release review sweep of the app source","description":"Two incidental findings (f9g.21 stale wording, f9g.22 unknown vs variable price) came out of re-reading merged code. Do it deliberately once, over the whole of app/src, before the first release: correctness, state handling, lifecycle, error paths, and places where the code and the specs disagree. File what is real; fix what is small.","acceptance_criteria":"AC-1: every file under app/src/main is reviewed.\nAC-2: each finding is either fixed with a test or filed as its own bead.\nAC-3: the gate stays green and the app still runs on the tablet.","status":"in_progress","priority":2,"issue_type":"task","assignee":"mabd","owner":"mabd.agent@gmail.com","created_at":"2026-09-23T06:04:19Z","created_by":"mabd","updated_at":"2026-09-23T06:04:20Z","started_at":"2026-09-23T06:04:20Z","labels":["docs","github"],"dependencies":[{"issue_id":"fedo-android-example-f9g.23","depends_on_id":"fedo-android-example-f9g","type":"parent-child","created_at":"2026-09-23T09:04:19Z","created_by":"mabd","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"fedo-android-example-f9g.22","title":"A missing price renders as 'Variable', which is a different claim from 'unknown'","description":"Price.parse returns Variable for anything that will not parse, including the empty string that a missing pricing block maps to. OpenRouter ships entries with no pricing object (the test fixture has one), and the list then reads 'Variable / 1M in' for a model whose price we simply do not know. '-1' genuinely means variable pricing; absent data does not.\n\nspecs/testing.md already assumes the distinction exists — it asserts Price.Unknown for an unparseable price — but the sealed interface has no Unknown case, so the spec cannot compile.\n\nAdd Price.Unknown, map unparseable and absent prices to it, and render it the way an unknown context window already renders: an em dash.","acceptance_criteria":"AC-1: an absent or unparseable price is Price.Unknown, not Price.Variable.\nAC-2: '-1' is still Price.Variable.\nAC-3: Unknown renders as the same em dash an unknown context window uses.\nAC-4: specs/testing.md's Price example compiles against the real type.","status":"closed","priority":2,"issue_type":"bug","assignee":"mabd","owner":"mabd.agent@gmail.com","created_at":"2026-09-23T06:01:38Z","created_by":"mabd","updated_at":"2026-09-23T06:03:47Z","started_at":"2026-09-23T06:01:40Z","closed_at":"2026-09-23T06:03:47Z","close_reason":"AC-1..AC-4: Unknown added and rendered as an em dash, -1 still Variable, spec example now compiles; verified on device","labels":["docs","github"],"dependencies":[{"issue_id":"fedo-android-example-f9g.22","depends_on_id":"fedo-android-example-f9g","type":"parent-child","created_at":"2026-09-23T09:01:37Z","created_by":"mabd","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"fedo-android-example-f9g.20","title":"Docs, badges and CI assume GitHub, but the only remote is Gitea","description":"git remote -v shows one remote: gitea ssh://git@git.mabd.dev:3101/fedo/fedo-android-example.git. There is no GitHub remote, and gh auth is not working here (invalid GITHUB_TOKEN).\n\nEverything written so far assumes github.com/getfedo/fedo-android-example: the README CI badge and clone URL, CONTRIBUTING's issue links, SECURITY.md's private advisory URL, .github/ISSUE_TEMPLATE and PULL_REQUEST_TEMPLATE, and .github/workflows/ci.yml with actions pinned by GitHub commit SHA.\n\nDecide which is true:\n- the repo is published to GitHub as well, and Gitea is a mirror - then nothing changes, but CI only runs once GitHub has the code;\n- the repo lives on Gitea only - then the badge, the clone URL, the advisory link and the issue links are wrong, and CI should be Gitea Actions (compatible syntax, but actions resolve through a configured mirror, not github.com).\n\nBlocks f9g.8 and f9g.9 from ever going green, and makes f9g.11 (repo metadata) ambiguous.","status":"closed","priority":2,"issue_type":"task","assignee":"mabd","owner":"mabd.agent@gmail.com","created_at":"2026-09-23T05:55:02Z","created_by":"mabd","updated_at":"2026-09-23T05:58:16Z","started_at":"2026-09-23T05:57:59Z","closed_at":"2026-09-23T05:58:16Z","close_reason":"Answered by the sibling repo: getfedo/fedo-ios-example is public on GitHub with CI, topics and homepage set, while its local clone also has only the Gitea remote. GitHub is the publish target; getfedo/fedo-android-example just does not exist yet (404). Docs stay as written; CONTRIBUTING now says so","labels":["docs","github"],"dependencies":[{"issue_id":"fedo-android-example-f9g.20","depends_on_id":"fedo-android-example-f9g","type":"parent-child","created_at":"2026-09-23T08:55:01Z","created_by":"mabd","metadata":"{}"}],"dependency_count":0,"dependent_count":3,"comment_count":0}
{"_type":"issue","id":"fedo-android-example-its.10","title":"Move the ViewModel's user-facing error strings into resources","description":"ModelsViewModel.userMessage() returns English sentences from Kotlin, so the two error messages the user reads are the only user-facing copy outside strings.xml. The constitution now says a ViewModel carries a @StringRes Int and the UI layer resolves it.","acceptance_criteria":"AC-1: no user-facing sentence remains in Kotlin outside strings.xml.\nAC-2: the error and refresh-error paths show the same text on screen as before.","status":"closed","priority":2,"issue_type":"task","assignee":"mabd","owner":"mabd.agent@gmail.com","created_at":"2026-09-23T05:04:59Z","created_by":"mabd","updated_at":"2026-09-23T05:08:49Z","started_at":"2026-09-23T05:05:27Z","closed_at":"2026-09-23T05:08:49Z","close_reason":"Rules added to the constitution and the one violation fixed; verified on device","labels":["app"],"dependencies":[{"issue_id":"fedo-android-example-its.10","depends_on_id":"fedo-android-example-its","type":"parent-child","created_at":"2026-09-23T08:04:59Z","created_by":"mabd","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
Expand Down
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ contextual feedback sheet on the other.
full scrolling description, plus the model id in monospace with a copy
action. An id missing from the catalogue shows a not-found state
by @mabd-agent
- App navigation scaffold: a bottom navigation bar with Models, Roadmap and
- App navigation scaffold: a floating bottom navigation bar with Models, Roadmap and
Settings, built on Navigation 3 (`NavDisplay` + a `@Serializable` `NavKey`
back stack). Models is the back-stack root, so system back from any other
destination returns to it. Roadmap and Settings are placeholders until the
Expand Down
2 changes: 2 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ dependencies {
implementation(libs.androidx.navigation3.runtime)
implementation(libs.androidx.navigation3.ui)
implementation(libs.androidx.lifecycle.viewmodel.navigation3)
implementation(libs.androidx.adaptive)
implementation(libs.androidx.compose.adaptive.navigation3)

implementation(platform(libs.koin.bom))
implementation(libs.koin.android)
Expand Down
22 changes: 22 additions & 0 deletions app/src/main/java/com/fedo/modelpulse/ui/Utils.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.fedo.modelpulse.ui

import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalLayoutDirection

@Composable
fun mergePaddingValues(
topSource: PaddingValues,
restSource: PaddingValues
): PaddingValues {
val layoutDirection = LocalLayoutDirection.current

return PaddingValues(
top = topSource.calculateTopPadding(),
bottom = restSource.calculateBottomPadding(),
start = restSource.calculateStartPadding(layoutDirection),
end = restSource.calculateEndPadding(layoutDirection)
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import android.content.ClipData
import android.content.res.Configuration
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
Expand Down Expand Up @@ -44,6 +45,7 @@ import com.fedo.modelpulse.data.Price
import com.fedo.modelpulse.data.contextLabel
import com.fedo.modelpulse.data.perMillionLabel
import com.fedo.modelpulse.data.relativeLabel
import com.fedo.modelpulse.ui.mergePaddingValues
import com.fedo.modelpulse.ui.theme.ModelPulseTheme
import java.math.BigDecimal
import java.time.Instant
Expand All @@ -56,6 +58,7 @@ internal fun ModelDetailRoute(
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
viewModel: ModelDetailViewModel = koinViewModel(),
contentPadding: PaddingValues = PaddingValues()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val clipboard = LocalClipboard.current
Expand All @@ -72,6 +75,7 @@ internal fun ModelDetailRoute(
}
},
modifier = modifier,
contentPadding = contentPadding
)
}

Expand All @@ -82,6 +86,7 @@ internal fun ModelDetailScreen(
onBackClick: () -> Unit,
onCopyId: (String) -> Unit,
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues()
) {
Scaffold(
modifier = modifier.fillMaxSize(),
Expand All @@ -106,23 +111,25 @@ internal fun ModelDetailScreen(
)
},
) { innerPadding ->
val mergedContentPadding = mergePaddingValues(innerPadding, contentPadding)

when (uiState) {
ModelDetailUiState.Loading -> LoadingState(Modifier.padding(innerPadding))
ModelDetailUiState.Loading -> LoadingState(Modifier.padding(mergedContentPadding))

ModelDetailUiState.NotFound -> Text(
text = stringResource(R.string.detail_not_found),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
modifier = Modifier
.padding(innerPadding)
.fillMaxWidth()
.padding(mergedContentPadding)
.padding(32.dp),
)

is ModelDetailUiState.Success -> ModelDetailContent(
model = uiState.model,
onCopyId = onCopyId,
modifier = Modifier.padding(innerPadding),
contentPadding = mergedContentPadding,
)
}
}
Expand All @@ -133,13 +140,16 @@ private fun ModelDetailContent(
model: AiModel,
onCopyId: (String) -> Unit,
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues()
) {
Column(
modifier = modifier
.fillMaxSize()
// A long description scrolls rather than clipping.
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 8.dp),
.padding(contentPadding)
.padding(horizontal = 16.dp, vertical = 8.dp)
,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(model.providerName, style = MaterialTheme.typography.titleMedium)
Expand Down
51 changes: 51 additions & 0 deletions app/src/main/java/com/fedo/modelpulse/ui/icons/IconAiFilled.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package com.fedo.modelpulse.ui.icons

import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp

val IconAiFilled: ImageVector
get() {
if (_AiFilled != null) {
return _AiFilled!!
}
_AiFilled = ImageVector.Builder(
name = "AiFilled",
defaultWidth = 100.dp,
defaultHeight = 100.dp,
viewportWidth = 100f,
viewportHeight = 100f
).apply {
path(fill = SolidColor(Color.Black)) {
moveTo(35f, 15f)
curveTo(35f, 30f, 40f, 45f, 70f, 50f)
curveTo(40f, 55f, 35f, 70f, 35f, 85f)
curveTo(35f, 70f, 30f, 55f, 0f, 50f)
curveTo(30f, 45f, 35f, 30f, 35f, 15f)
close()
}
path(fill = SolidColor(Color.Black)) {
moveTo(75f, 10f)
curveTo(75f, 17f, 77.5f, 22.5f, 90f, 25f)
curveTo(77.5f, 27.5f, 75f, 33f, 75f, 40f)
curveTo(75f, 33f, 72.5f, 27.5f, 60f, 25f)
curveTo(72.5f, 22.5f, 75f, 17f, 75f, 10f)
close()
}
path(fill = SolidColor(Color.Black)) {
moveTo(75f, 60f)
curveTo(75f, 67f, 77.5f, 72.5f, 90f, 75f)
curveTo(77.5f, 77.5f, 75f, 83f, 75f, 90f)
curveTo(75f, 83f, 72.5f, 77.5f, 60f, 75f)
curveTo(72.5f, 72.5f, 75f, 67f, 75f, 60f)
close()
}
}.build()

return _AiFilled!!
}

@Suppress("ObjectPropertyName")
private var _AiFilled: ImageVector? = null
Loading