Skip to content

Commit 638421d

Browse files
authored
Merge pull request #4 from forge-welldone/feature/SIG-2-design-system-foundation
SIG-2 Design system foundation: tokens, typography, primitives, icons, logo
2 parents 4b689e2 + 5b4f619 commit 638421d

21 files changed

Lines changed: 1351 additions & 8 deletions

File tree

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
package com.remotesigner.ui.components
2+
3+
import androidx.compose.foundation.layout.Box
4+
import androidx.compose.foundation.layout.fillMaxSize
5+
import androidx.compose.runtime.Composable
6+
import androidx.compose.runtime.mutableStateOf
7+
import androidx.compose.runtime.remember
8+
import androidx.compose.ui.Modifier
9+
import androidx.compose.ui.platform.testTag
10+
import androidx.compose.ui.test.assertIsDisplayed
11+
import androidx.compose.ui.test.assertIsNotEnabled
12+
import androidx.compose.ui.test.junit4.createComposeRule
13+
import androidx.compose.ui.test.onNodeWithContentDescription
14+
import androidx.compose.ui.test.onNodeWithTag
15+
import androidx.compose.ui.test.onNodeWithText
16+
import androidx.compose.ui.test.performClick
17+
import com.remotesigner.ui.branding.AppLogo
18+
import com.remotesigner.ui.theme.SatoshiSignerTheme
19+
import org.junit.Assert.assertEquals
20+
import org.junit.Rule
21+
import org.junit.Test
22+
import org.junit.runner.RunWith
23+
import org.junit.runners.Parameterized
24+
25+
/**
26+
* Each primitive renders in both `darkTheme = true` and `darkTheme = false`
27+
* so light-only or dark-only regressions surface in CI. The class is
28+
* parameterised over `darkTheme` and every test goes through
29+
* [setThemedContent], so adding a new primitive test gives both modes for
30+
* free.
31+
*/
32+
@RunWith(Parameterized::class)
33+
class PrimitivesTest(private val darkTheme: Boolean) {
34+
35+
companion object {
36+
@JvmStatic
37+
@Parameterized.Parameters(name = "darkTheme={0}")
38+
fun modes(): List<Array<Any>> = listOf(arrayOf(true), arrayOf(false))
39+
}
40+
41+
@get:Rule
42+
val composeTestRule = createComposeRule()
43+
44+
private fun setThemedContent(content: @Composable () -> Unit) {
45+
composeTestRule.setContent {
46+
SatoshiSignerTheme(darkTheme = darkTheme) { content() }
47+
}
48+
}
49+
50+
@Test
51+
fun appButton_clicks() {
52+
var clicks = 0
53+
setThemedContent {
54+
AppButton(text = "Open PSBT file", onClick = { clicks++ })
55+
}
56+
composeTestRule.onNodeWithText("Open PSBT file").performClick()
57+
assertEquals(1, clicks)
58+
}
59+
60+
@Test
61+
fun appButton_renders_allVariants() {
62+
setThemedContent {
63+
Box(Modifier.testTag("variants")) {
64+
AppButton(text = "Primary", onClick = {}, variant = AppButtonVariant.Primary)
65+
AppButton(text = "Secondary", onClick = {}, variant = AppButtonVariant.Secondary)
66+
AppButton(text = "Ghost", onClick = {}, variant = AppButtonVariant.Ghost)
67+
AppButton(text = "Danger", onClick = {}, variant = AppButtonVariant.Danger)
68+
}
69+
}
70+
composeTestRule.onNodeWithTag("variants").assertIsDisplayed()
71+
}
72+
73+
@Test
74+
fun appButton_disabled_isNotEnabled() {
75+
// Foundation 1.7's clickable adds the OnClick semantics action even
76+
// when disabled (only the Disabled flag is gated on `enabled`), so
77+
// asserting the absence of a click action would fail. The disabled
78+
// flag is the reliable contract.
79+
setThemedContent {
80+
AppButton(text = "Disabled", onClick = {}, enabled = false)
81+
}
82+
composeTestRule.onNodeWithText("Disabled")
83+
.assertIsDisplayed()
84+
.assertIsNotEnabled()
85+
}
86+
87+
@Test
88+
fun pill_renders_eachTone() {
89+
setThemedContent {
90+
Box {
91+
Pill(text = "Connected", tone = PillTone.Good)
92+
Pill(text = "Waiting", tone = PillTone.Warn)
93+
Pill(text = "Offline", tone = PillTone.Bad)
94+
Pill(text = "Active", tone = PillTone.Accent)
95+
Pill(text = "Idle", tone = PillTone.Neutral)
96+
}
97+
}
98+
composeTestRule.onNodeWithText("CONNECTED").assertIsDisplayed()
99+
composeTestRule.onNodeWithText("OFFLINE").assertIsDisplayed()
100+
}
101+
102+
@Test
103+
fun card_displaysContent() {
104+
setThemedContent {
105+
VaultCard(modifier = Modifier.testTag("card")) {
106+
androidx.compose.material3.Text("Inside card")
107+
}
108+
}
109+
composeTestRule.onNodeWithTag("card").assertIsDisplayed()
110+
composeTestRule.onNodeWithText("Inside card").assertIsDisplayed()
111+
}
112+
113+
@Test
114+
fun eyebrow_uppercases() {
115+
setThemedContent { Eyebrow("Inbox") }
116+
composeTestRule.onNodeWithText("INBOX").assertIsDisplayed()
117+
}
118+
119+
@Test
120+
fun labeledRow_displaysLabelAndValue() {
121+
setThemedContent {
122+
LabeledRow(label = "Fee", value = "0.00000213 BTC")
123+
}
124+
composeTestRule.onNodeWithText("Fee").assertIsDisplayed()
125+
composeTestRule.onNodeWithText("0.00000213 BTC").assertIsDisplayed()
126+
}
127+
128+
@Test
129+
fun addr_truncatesLong() {
130+
setThemedContent {
131+
Addr(value = "bc1qmek5jz2m9l4k6s7yqfdjl2mxv3lqmlv6", head = 6, tail = 6)
132+
}
133+
composeTestRule.onNodeWithText("bc1qme…lqmlv6", substring = true).assertIsDisplayed()
134+
}
135+
136+
@Test
137+
fun screenHeader_dispatchesBack() {
138+
var backs = 0
139+
setThemedContent {
140+
ScreenHeader(title = "Review", onBack = { backs++ })
141+
}
142+
composeTestRule.onNodeWithContentDescription("Back").performClick()
143+
assertEquals(1, backs)
144+
}
145+
146+
@Test
147+
fun bottomSheet_invokesDismiss_onScrimClick() {
148+
var dismissed = 0
149+
setThemedContent {
150+
val visible = remember { mutableStateOf(true) }
151+
Box(Modifier.fillMaxSize().testTag("root")) {
152+
BottomSheetOverlay(
153+
visible = visible.value,
154+
onDismiss = {
155+
dismissed++
156+
visible.value = false
157+
},
158+
) {
159+
androidx.compose.material3.Text(
160+
"Sheet body",
161+
modifier = Modifier.testTag("body"),
162+
)
163+
}
164+
}
165+
}
166+
composeTestRule.onNodeWithTag("body").assertIsDisplayed()
167+
composeTestRule.onNodeWithTag(BOTTOM_SHEET_SCRIM_TAG).performClick()
168+
composeTestRule.waitForIdle()
169+
assertEquals(1, dismissed)
170+
}
171+
172+
@Test
173+
fun bottomSheet_tapOnSheetBody_doesNotDismiss() {
174+
// Regression: the sheet container must consume taps — without it,
175+
// taps inside the sheet fall through to the scrim and dismiss.
176+
var dismissed = 0
177+
setThemedContent {
178+
Box(Modifier.fillMaxSize()) {
179+
BottomSheetOverlay(
180+
visible = true,
181+
onDismiss = { dismissed++ },
182+
) {
183+
androidx.compose.material3.Text(
184+
"Sheet body",
185+
modifier = Modifier.testTag("body"),
186+
)
187+
}
188+
}
189+
}
190+
composeTestRule.onNodeWithTag("body").performClick()
191+
composeTestRule.waitForIdle()
192+
assertEquals(0, dismissed)
193+
}
194+
195+
@Test
196+
fun appLogo_renders() {
197+
setThemedContent {
198+
AppLogo(modifier = Modifier.testTag("logo"))
199+
}
200+
composeTestRule.onNodeWithTag("logo").assertIsDisplayed()
201+
}
202+
203+
@Test
204+
fun spinner_renders() {
205+
setThemedContent {
206+
Spinner(modifier = Modifier.testTag("spin"))
207+
}
208+
composeTestRule.onNodeWithTag("spin").assertIsDisplayed()
209+
}
210+
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
package com.remotesigner.ui.branding
2+
3+
import androidx.compose.foundation.Canvas
4+
import androidx.compose.foundation.layout.size
5+
import androidx.compose.runtime.Composable
6+
import androidx.compose.ui.Modifier
7+
import androidx.compose.ui.geometry.Offset
8+
import androidx.compose.ui.geometry.Size
9+
import androidx.compose.ui.graphics.Brush
10+
import androidx.compose.ui.graphics.Color
11+
import androidx.compose.ui.graphics.Path
12+
import androidx.compose.ui.graphics.StrokeCap
13+
import androidx.compose.ui.graphics.StrokeJoin
14+
import androidx.compose.ui.graphics.drawscope.Stroke
15+
import androidx.compose.ui.unit.Dp
16+
import androidx.compose.ui.unit.dp
17+
18+
private val LogoStart = Color(0xFFD97706)
19+
private val LogoEnd = Color(0xFFE11D48)
20+
21+
/**
22+
* Faithful port of the prototype `Logo()` (`docs/icon.svg`) — gradient
23+
* rounded square, bracketed inner chip with side ticks, white check.
24+
*
25+
* The whole thing is drawn into a `viewportSize=108` coordinate space and
26+
* scaled to the requested `size`.
27+
*/
28+
@Composable
29+
fun AppLogo(
30+
modifier: Modifier = Modifier,
31+
size: Dp = 28.dp,
32+
) {
33+
Canvas(modifier = modifier.size(size)) {
34+
val s = this.size.width / 108f
35+
fun p(x: Float, y: Float) = Offset(x * s, y * s)
36+
37+
val gradient = Brush.linearGradient(
38+
colors = listOf(LogoStart, LogoEnd),
39+
start = p(0f, 0f),
40+
end = p(108f, 108f),
41+
)
42+
43+
// Background rounded square (radius 24)
44+
val bg = Path().apply {
45+
val r = 24f * s
46+
val w = 108f * s
47+
addRoundRect(
48+
androidx.compose.ui.geometry.RoundRect(
49+
left = 0f,
50+
top = 0f,
51+
right = w,
52+
bottom = w,
53+
radiusX = r,
54+
radiusY = r,
55+
),
56+
)
57+
}
58+
drawPath(bg, gradient)
59+
60+
val white = Color.White
61+
62+
// Bracketed inner chip — superellipse-ish rounded square 32→76
63+
val chip = Path().apply {
64+
moveTo(40f * s, 32f * s)
65+
lineTo(68f * s, 32f * s)
66+
quadraticTo(76f * s, 32f * s, 76f * s, 40f * s)
67+
lineTo(76f * s, 68f * s)
68+
quadraticTo(76f * s, 76f * s, 68f * s, 76f * s)
69+
lineTo(40f * s, 76f * s)
70+
quadraticTo(32f * s, 76f * s, 32f * s, 68f * s)
71+
lineTo(32f * s, 40f * s)
72+
quadraticTo(32f * s, 32f * s, 40f * s, 32f * s)
73+
close()
74+
}
75+
drawPath(
76+
chip,
77+
color = white,
78+
style = Stroke(width = 2.5f * s, cap = StrokeCap.Round, join = StrokeJoin.Round),
79+
)
80+
81+
// Side ticks (4 sides × 3 ticks)
82+
val tickStroke = Stroke(width = 2f * s, cap = StrokeCap.Round)
83+
listOf(44, 54, 64).forEach { y ->
84+
drawLine(white, p(32f, y.toFloat()), p(22f, y.toFloat()), strokeWidth = tickStroke.width, cap = tickStroke.cap)
85+
drawLine(white, p(76f, y.toFloat()), p(86f, y.toFloat()), strokeWidth = tickStroke.width, cap = tickStroke.cap)
86+
}
87+
listOf(44, 54, 64).forEach { x ->
88+
drawLine(white, p(x.toFloat(), 32f), p(x.toFloat(), 22f), strokeWidth = tickStroke.width, cap = tickStroke.cap)
89+
drawLine(white, p(x.toFloat(), 76f), p(x.toFloat(), 86f), strokeWidth = tickStroke.width, cap = tickStroke.cap)
90+
}
91+
92+
// Check (43,54)→(50,61)→(67,44)
93+
val check = Path().apply {
94+
moveTo(43f * s, 54f * s)
95+
lineTo(50f * s, 61f * s)
96+
lineTo(67f * s, 44f * s)
97+
}
98+
drawPath(
99+
check,
100+
color = white,
101+
style = Stroke(width = 3f * s, cap = StrokeCap.Round, join = StrokeJoin.Round),
102+
)
103+
}
104+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package com.remotesigner.ui.components
2+
3+
import androidx.compose.material3.Text
4+
import androidx.compose.runtime.Composable
5+
import androidx.compose.ui.Modifier
6+
import androidx.compose.ui.graphics.Color
7+
import com.remotesigner.ui.theme.LocalVaultColors
8+
import com.remotesigner.ui.theme.LocalVaultTypography
9+
10+
@Composable
11+
fun Addr(
12+
value: String,
13+
modifier: Modifier = Modifier,
14+
head: Int = 8,
15+
tail: Int = 8,
16+
color: Color? = null,
17+
) {
18+
require(head >= 0 && tail >= 0) { "head/tail must be non-negative (head=$head, tail=$tail)" }
19+
val colors = LocalVaultColors.current
20+
val typography = LocalVaultTypography.current
21+
val display = if (value.length > head + tail + 3) {
22+
"${value.take(head)}${value.takeLast(tail)}"
23+
} else {
24+
value
25+
}
26+
Text(
27+
text = display,
28+
style = typography.mono.copy(color = color ?: colors.text),
29+
modifier = modifier,
30+
)
31+
}

0 commit comments

Comments
 (0)