feat: wheel screen, play hub, storage data source, iOS wheel/play views

This commit is contained in:
null 2026-06-22 10:25:58 -05:00
parent be781adbde
commit 4dcafa688e
8 changed files with 62 additions and 83 deletions

5
.gitignore vendored
View File

@ -27,6 +27,9 @@ BUILD_SUMMARY.md
SCRIPTS.md SCRIPTS.md
.learnings/ .learnings/
serviceAccount.json
closer-app-22014-firebase-adminsdk-fbsvc-ed20bf6003.json
# Build artifacts # Build artifacts
*.apk *.apk
*.aab *.aab
@ -60,3 +63,5 @@ closer_partner_proof_reveal_privacy.md
app/google-services.json.bk app/google-services.json.bk
app/GoogleService-Info.plist app/GoogleService-Info.plist
docs/SUBSCRIPTION_GO_LIVE.md docs/SUBSCRIPTION_GO_LIVE.md
ios_encrypt.md
closer-app-22014-firebase-adminsdk-fbsvc-ed20bf6003.json

View File

@ -1,7 +1,10 @@
package app.closer.data.remote package app.closer.data.remote
import android.content.Context
import android.net.Uri import android.net.Uri
import com.google.firebase.storage.FirebaseStorage import com.google.firebase.storage.FirebaseStorage
import com.google.firebase.storage.StorageMetadata
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@ -9,14 +12,22 @@ import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException import kotlin.coroutines.resumeWithException
@Singleton @Singleton
class FirebaseStorageDataSource @Inject constructor() { class FirebaseStorageDataSource @Inject constructor(
@ApplicationContext private val context: Context
) {
private val storage = FirebaseStorage.getInstance() private val storage = FirebaseStorage.getInstance()
suspend fun uploadProfilePhoto(uid: String, uri: Uri): String = suspend fun uploadProfilePhoto(uid: String, uri: Uri): String =
suspendCancellableCoroutine { cont -> suspendCancellableCoroutine { cont ->
val ref = storage.reference.child("users/$uid/profile.jpg") val ref = storage.reference.child("users/$uid/profile.jpg")
ref.putFile(uri) val mimeType = context.contentResolver.getType(uri)
?.takeIf { it.startsWith("image/") }
?: "image/jpeg"
val metadata = StorageMetadata.Builder()
.setContentType(mimeType)
.build()
ref.putFile(uri, metadata)
.continueWithTask { ref.downloadUrl } .continueWithTask { ref.downloadUrl }
.addOnSuccessListener { cont.resume(it.toString()) } .addOnSuccessListener { cont.resume(it.toString()) }
.addOnFailureListener { cont.resumeWithException(it) } .addOnFailureListener { cont.resumeWithException(it) }

View File

@ -1,5 +1,6 @@
package app.closer.ui.play package app.closer.ui.play
import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@ -20,7 +21,6 @@ import androidx.compose.material.icons.filled.Done
import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -31,10 +31,13 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import app.closer.R
import app.closer.core.navigation.AppRoute import app.closer.core.navigation.AppRoute
import app.closer.ui.components.CategoryGlyph import app.closer.ui.components.CategoryGlyph
import app.closer.ui.components.CloserActionButton import app.closer.ui.components.CloserActionButton
@ -45,7 +48,6 @@ import app.closer.ui.components.CloserPill
import app.closer.ui.components.CloserRadii import app.closer.ui.components.CloserRadii
import app.closer.ui.theme.CloserPalette import app.closer.ui.theme.CloserPalette
import app.closer.ui.theme.closerBackgroundBrush import app.closer.ui.theme.closerBackgroundBrush
import app.closer.ui.theme.closerBrandGlyphBrush
import app.closer.ui.theme.closerPlayCardBrush import app.closer.ui.theme.closerPlayCardBrush
@Composable @Composable
@ -685,17 +687,17 @@ private fun WheelGlyph(
Surface( Surface(
modifier = modifier, modifier = modifier,
shape = RoundedCornerShape(26.dp), shape = RoundedCornerShape(26.dp),
color = CloserPalette.PurpleDeep color = MaterialTheme.colorScheme.surface.copy(alpha = 0.62f)
) { ) {
Box( Box(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
modifier = Modifier.background(closerBrandGlyphBrush()) modifier = Modifier.padding(5.dp)
) { ) {
Icon( Image(
imageVector = Icons.Filled.PlayArrow, painter = painterResource(R.drawable.illustration_spin_wheel),
contentDescription = null, contentDescription = null,
tint = MaterialTheme.colorScheme.surface, contentScale = ContentScale.Fit,
modifier = Modifier.size(34.dp) modifier = Modifier.fillMaxSize()
) )
} }
} }

View File

@ -10,6 +10,7 @@ import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@ -43,15 +44,17 @@ import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import app.closer.R
import app.closer.ui.theme.CloserPalette import app.closer.ui.theme.CloserPalette
import app.closer.ui.theme.closerBackgroundBrush import app.closer.ui.theme.closerBackgroundBrush
import app.closer.ui.theme.closerWheelSegmentColors
@Composable @Composable
fun SpinWheelScreen( fun SpinWheelScreen(
@ -231,7 +234,6 @@ private fun WheelSpinner(
spunAndReady: Boolean, spunAndReady: Boolean,
rotation: Float rotation: Float
) { ) {
val segmentColors = closerWheelSegmentColors()
val idleTransition = rememberInfiniteTransition(label = "wheel_idle") val idleTransition = rememberInfiniteTransition(label = "wheel_idle")
val idlePulse by idleTransition.animateFloat( val idlePulse by idleTransition.animateFloat(
initialValue = 0.98f, initialValue = 0.98f,
@ -260,24 +262,18 @@ private fun WheelSpinner(
.scale(if (isSpinning) 1f else idlePulse), .scale(if (isSpinning) 1f else idlePulse),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Canvas( Image(
painter = painterResource(R.drawable.illustration_spin_wheel),
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier modifier = Modifier
.size(236.dp) .size(236.dp)
.rotate(if (isSpinning) rotation else 0f) .rotate(if (isSpinning) rotation else 0f)
)
Canvas(
modifier = Modifier.size(236.dp)
) { ) {
val sweep = 360f / segmentColors.size
segmentColors.forEachIndexed { index, color ->
drawArc(
color = color,
startAngle = -90f + (index * sweep),
sweepAngle = sweep,
useCenter = true
)
}
drawCircle(
color = Color.White.copy(alpha = 0.78f),
radius = size.minDimension * 0.24f
)
drawCircle( drawCircle(
color = wheelRingColor, color = wheelRingColor,
style = Stroke(width = 7.dp.toPx()) style = Stroke(width = 7.dp.toPx())

Binary file not shown.

After

Width:  |  Height:  |  Size: 748 KiB

View File

@ -90,9 +90,16 @@ struct GameCard: View {
RoundedRectangle(cornerRadius: CloserRadius.medium) RoundedRectangle(cornerRadius: CloserRadius.medium)
.fill(color.opacity(0.15)) .fill(color.opacity(0.15))
.frame(width: 60, height: 60) .frame(width: 60, height: 60)
Image(systemName: icon) if gameType == .wheel {
.font(.title2) Image("illustration-spin-wheel")
.foregroundColor(color) .resizable()
.scaledToFit()
.frame(width: 52, height: 52)
} else {
Image(systemName: icon)
.font(.title2)
.foregroundColor(color)
}
} }
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
@ -716,4 +723,4 @@ extension String {
func replacing(_ target: String, with replacement: String) -> String { func replacing(_ target: String, with replacement: String) -> String {
replacingOccurrences(of: target, with: replacement) replacingOccurrences(of: target, with: replacement)
} }
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 748 KiB

View File

@ -87,29 +87,29 @@ struct SpinWheelView: View {
// Wheel // Wheel
ZStack { ZStack {
ForEach(slices.indices, id: \.self) { index in Image("illustration-spin-wheel")
WheelSlice( .resizable()
label: slices[index].label, .scaledToFit()
color: slices[index].color, .frame(width: 320, height: 320)
startAngle: Double(index) * (360.0 / Double(slices.count)), .rotationEffect(.degrees(rotation))
endAngle: Double(index + 1) * (360.0 / Double(slices.count))
)
}
// Center circle // Center circle
Circle() Circle()
.fill(Color.closerBackground) .fill(Color.closerBackground)
.frame(width: 60, height: 60) .frame(width: 60, height: 60)
.closerShadow(level: .medium) .closerShadow(level: .medium)
Circle()
.stroke(Color.closerSurface.opacity(0.9), lineWidth: 8)
.frame(width: 320, height: 320)
// Pointer (top) // Pointer (top)
Image(systemName: "arrowtriangle.down.fill") Image(systemName: "arrowtriangle.down.fill")
.font(.title) .font(.title)
.foregroundColor(.closerDanger) .foregroundColor(.closerPrimary)
.offset(y: -160) .offset(y: -160)
} }
.frame(width: 320, height: 320) .frame(width: 320, height: 320)
.rotationEffect(.degrees(rotation))
.animation(isSpinning ? .spring(response: 1.5, dampingFraction: 0.6) : .default, value: rotation) .animation(isSpinning ? .spring(response: 1.5, dampingFraction: 0.6) : .default, value: rotation)
// Result // Result
@ -180,47 +180,6 @@ struct SpinWheelView: View {
} }
} }
// MARK: - Wheel Slice
struct WheelSlice: View {
let label: String
let color: Color
let startAngle: Double
let endAngle: Double
var body: some View {
GeometryReader { geo in
let center = CGPoint(x: geo.size.width / 2, y: geo.size.height / 2)
let radius = min(geo.size.width, geo.size.height) / 2
Path { path in
path.move(to: center)
path.addArc(
center: center,
radius: radius,
startAngle: .degrees(startAngle - 90),
endAngle: .degrees(endAngle - 90),
clockwise: false
)
path.closeSubpath()
}
.fill(color.opacity(0.3))
// Label
let midAngle = (startAngle + endAngle) / 2 - 90
let labelRadius = radius * 0.7
let x = center.x + labelRadius * cos(CGFloat(midAngle) * .pi / 180)
let y = center.y + labelRadius * sin(CGFloat(midAngle) * .pi / 180)
Text(label)
.font(.system(size: 10, weight: .semibold))
.foregroundColor(.closerText)
.position(x: x, y: y)
.rotationEffect(.degrees(midAngle + 90))
}
}
}
// MARK: - Wheel Session // MARK: - Wheel Session
struct WheelSessionView: View { struct WheelSessionView: View {
@ -375,4 +334,3 @@ struct WheelHistoryView: View {
} }
} }
} }