add support button, icon view
This commit is contained in:
@@ -357,6 +357,9 @@
|
||||
<string name="profile_limited_features">Limited features</string>
|
||||
<string name="profile_upgrade_to_pro">Upgrade to Pro</string>
|
||||
<string name="profile_manage_subscription">Manage your subscription in the Google Play Store</string>
|
||||
<string name="profile_support">Support</string>
|
||||
<string name="profile_contact_support">Contact Support</string>
|
||||
<string name="profile_contact_support_subtitle">Get help with your account</string>
|
||||
|
||||
<!-- Settings -->
|
||||
<string name="settings_title">Settings</string>
|
||||
|
||||
@@ -9,7 +9,7 @@ package com.example.casera.network
|
||||
*/
|
||||
object ApiConfig {
|
||||
// ⚠️ CHANGE THIS TO TOGGLE ENVIRONMENT ⚠️
|
||||
val CURRENT_ENV = Environment.LOCAL
|
||||
val CURRENT_ENV = Environment.DEV
|
||||
|
||||
enum class Environment {
|
||||
LOCAL,
|
||||
|
||||
@@ -11,6 +11,7 @@ import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -250,6 +251,48 @@ fun ProfileScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Contact Support Section
|
||||
val uriHandler = LocalUriHandler.current
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
uriHandler.openUri("mailto:caseraSupport@treymail.com?subject=Casera%20Support%20Request")
|
||||
},
|
||||
shape = RoundedCornerShape(AppRadius.md),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(AppSpacing.lg),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(AppSpacing.xs)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(Res.string.profile_contact_support),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Text(
|
||||
text = stringResource(Res.string.profile_contact_support_subtitle),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
imageVector = Icons.Default.Email,
|
||||
contentDescription = stringResource(Res.string.profile_contact_support),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Subscription Section - Only show if limitations are enabled
|
||||
if (currentSubscription?.limitationsEnabled == true) {
|
||||
Divider(modifier = Modifier.padding(vertical = AppSpacing.sm))
|
||||
|
||||
@@ -545,6 +545,11 @@ enum L10n {
|
||||
static var emailNotifications: String { String(localized: "profile_email_notifications") }
|
||||
static var emailTaskCompleted: String { String(localized: "profile_email_task_completed") }
|
||||
static var emailTaskCompletedDescription: String { String(localized: "profile_email_task_completed_description") }
|
||||
|
||||
// Support
|
||||
static var support: String { String(localized: "profile_support") }
|
||||
static var contactSupport: String { String(localized: "profile_contact_support") }
|
||||
static var contactSupportSubtitle: String { String(localized: "profile_contact_support_subtitle") }
|
||||
}
|
||||
|
||||
// MARK: - Settings
|
||||
|
||||
@@ -19372,6 +19372,39 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"profile_support" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Support"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"profile_contact_support" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Contact Support"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"profile_contact_support_subtitle" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Get help with your account"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"profile_subscription" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
|
||||
@@ -139,6 +139,22 @@ struct ProfileTabView: View {
|
||||
}
|
||||
.listRowBackground(Color.appBackgroundSecondary)
|
||||
|
||||
Section(L10n.Profile.support) {
|
||||
Button(action: {
|
||||
sendSupportEmail()
|
||||
}) {
|
||||
HStack {
|
||||
Label(L10n.Profile.contactSupport, systemImage: "envelope")
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
Spacer()
|
||||
Image(systemName: "arrow.up.right")
|
||||
.font(.caption)
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.listRowBackground(Color.appBackgroundSecondary)
|
||||
|
||||
Section {
|
||||
Button(action: {
|
||||
showingLogoutAlert = true
|
||||
@@ -193,4 +209,14 @@ struct ProfileTabView: View {
|
||||
Text(L10n.Profile.purchasesRestoredMessage)
|
||||
}
|
||||
}
|
||||
|
||||
private func sendSupportEmail() {
|
||||
let email = "caseraSupport@treymail.com"
|
||||
let subject = "Casera Support Request"
|
||||
let urlString = "mailto:\(email)?subject=\(subject.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? subject)"
|
||||
|
||||
if let url = URL(string: urlString) {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
323
iosApp/iosApp/Subviews/Common/MyCribIconView.swift
Normal file
323
iosApp/iosApp/Subviews/Common/MyCribIconView.swift
Normal file
@@ -0,0 +1,323 @@
|
||||
import SwiftUI
|
||||
|
||||
/// SVG viewBox dimensions for coordinate conversion
|
||||
private let svgSize: CGFloat = 512
|
||||
|
||||
/// Optical center adjustment (checkmark visual weight is left-heavy)
|
||||
private let opticalCenterOffset: CGFloat = 8
|
||||
|
||||
/// Animatable house roof path (right side)
|
||||
struct HouseRightPath: Shape {
|
||||
var trimEnd: CGFloat = 1.0
|
||||
|
||||
var animatableData: CGFloat {
|
||||
get { trimEnd }
|
||||
set { trimEnd = newValue }
|
||||
}
|
||||
|
||||
func path(in rect: CGRect) -> Path {
|
||||
let scale = min(rect.width, rect.height) / svgSize
|
||||
let ox = opticalCenterOffset * scale // optical offset
|
||||
|
||||
var path = Path()
|
||||
// M256 108 L375 200 Q392 212 392 235 L392 345 Q392 375 362 375 L335 375
|
||||
path.move(to: CGPoint(x: 256 * scale + ox, y: 108 * scale))
|
||||
path.addLine(to: CGPoint(x: 375 * scale + ox, y: 200 * scale))
|
||||
path.addQuadCurve(
|
||||
to: CGPoint(x: 392 * scale + ox, y: 235 * scale),
|
||||
control: CGPoint(x: 392 * scale + ox, y: 212 * scale)
|
||||
)
|
||||
path.addLine(to: CGPoint(x: 392 * scale + ox, y: 345 * scale))
|
||||
path.addQuadCurve(
|
||||
to: CGPoint(x: 362 * scale + ox, y: 375 * scale),
|
||||
control: CGPoint(x: 392 * scale + ox, y: 375 * scale)
|
||||
)
|
||||
path.addLine(to: CGPoint(x: 335 * scale + ox, y: 375 * scale))
|
||||
|
||||
return path.trimmedPath(from: 0, to: trimEnd)
|
||||
}
|
||||
}
|
||||
|
||||
/// Animatable house roof path (left side)
|
||||
struct HouseLeftPath: Shape {
|
||||
var trimEnd: CGFloat = 1.0
|
||||
|
||||
var animatableData: CGFloat {
|
||||
get { trimEnd }
|
||||
set { trimEnd = newValue }
|
||||
}
|
||||
|
||||
func path(in rect: CGRect) -> Path {
|
||||
let scale = min(rect.width, rect.height) / svgSize
|
||||
let ox = opticalCenterOffset * scale // optical offset
|
||||
|
||||
var path = Path()
|
||||
// M256 108 L137 200 Q120 212 120 235 L120 345 Q120 375 150 375 L177 375
|
||||
path.move(to: CGPoint(x: 256 * scale + ox, y: 108 * scale))
|
||||
path.addLine(to: CGPoint(x: 137 * scale + ox, y: 200 * scale))
|
||||
path.addQuadCurve(
|
||||
to: CGPoint(x: 120 * scale + ox, y: 235 * scale),
|
||||
control: CGPoint(x: 120 * scale + ox, y: 212 * scale)
|
||||
)
|
||||
path.addLine(to: CGPoint(x: 120 * scale + ox, y: 345 * scale))
|
||||
path.addQuadCurve(
|
||||
to: CGPoint(x: 150 * scale + ox, y: 375 * scale),
|
||||
control: CGPoint(x: 120 * scale + ox, y: 375 * scale)
|
||||
)
|
||||
path.addLine(to: CGPoint(x: 177 * scale + ox, y: 375 * scale))
|
||||
|
||||
return path.trimmedPath(from: 0, to: trimEnd)
|
||||
}
|
||||
}
|
||||
|
||||
/// Animatable checkmark path
|
||||
struct CheckmarkPath: Shape {
|
||||
var trimEnd: CGFloat = 1.0
|
||||
|
||||
var animatableData: CGFloat {
|
||||
get { trimEnd }
|
||||
set { trimEnd = newValue }
|
||||
}
|
||||
|
||||
func path(in rect: CGRect) -> Path {
|
||||
let scale = min(rect.width, rect.height) / svgSize
|
||||
let ox = opticalCenterOffset * scale // optical offset
|
||||
|
||||
var path = Path()
|
||||
// M175 320 L235 380 L355 250
|
||||
path.move(to: CGPoint(x: 175 * scale + ox, y: 320 * scale))
|
||||
path.addLine(to: CGPoint(x: 235 * scale + ox, y: 380 * scale))
|
||||
path.addLine(to: CGPoint(x: 355 * scale + ox, y: 250 * scale))
|
||||
|
||||
return path.trimmedPath(from: 0, to: trimEnd)
|
||||
}
|
||||
}
|
||||
|
||||
/// Animatable window (rounded rectangle)
|
||||
struct WindowShape: Shape {
|
||||
var scale: CGFloat = 1.0
|
||||
|
||||
var animatableData: CGFloat {
|
||||
get { scale }
|
||||
set { scale = newValue }
|
||||
}
|
||||
|
||||
func path(in rect: CGRect) -> Path {
|
||||
let viewScale = min(rect.width, rect.height) / svgSize
|
||||
let ox = opticalCenterOffset * viewScale // optical offset
|
||||
|
||||
// Window at (230, 175) with size 52x52 and corner radius 12
|
||||
let windowX: CGFloat = 230
|
||||
let windowY: CGFloat = 175
|
||||
let windowSize: CGFloat = 52
|
||||
let cornerRadius: CGFloat = 12
|
||||
|
||||
// Center point of window (with optical offset)
|
||||
let centerX = (windowX + windowSize / 2) * viewScale + ox
|
||||
let centerY = (windowY + windowSize / 2) * viewScale
|
||||
|
||||
// Scaled dimensions
|
||||
let scaledSize = windowSize * viewScale * scale
|
||||
let scaledRadius = cornerRadius * viewScale * scale
|
||||
|
||||
let windowRect = CGRect(
|
||||
x: centerX - scaledSize / 2,
|
||||
y: centerY - scaledSize / 2,
|
||||
width: scaledSize,
|
||||
height: scaledSize
|
||||
)
|
||||
|
||||
return Path(roundedRect: windowRect, cornerRadius: scaledRadius)
|
||||
}
|
||||
}
|
||||
|
||||
/// Background rounded rectangle shape
|
||||
struct BackgroundShape: Shape {
|
||||
func path(in rect: CGRect) -> Path {
|
||||
let scale = min(rect.width, rect.height) / svgSize
|
||||
|
||||
// rect at (24, 24) with 464x464 size and rx/ry=100
|
||||
let bgRect = CGRect(
|
||||
x: 24 * scale,
|
||||
y: 24 * scale,
|
||||
width: 464 * scale,
|
||||
height: 464 * scale
|
||||
)
|
||||
|
||||
return Path(roundedRect: bgRect, cornerRadius: 100 * scale)
|
||||
}
|
||||
}
|
||||
|
||||
/// MyCrib icon view with all paths for animation
|
||||
struct MyCribIconView: View {
|
||||
// Animation progress values (0 to 1)
|
||||
var houseLeftProgress: CGFloat = 1.0
|
||||
var houseRightProgress: CGFloat = 1.0
|
||||
var windowScale: CGFloat = 1.0
|
||||
var checkmarkProgress: CGFloat = 1.0
|
||||
var showBackground: Bool = true
|
||||
var backgroundOpacity: Double = 1.0
|
||||
|
||||
// Colors
|
||||
var backgroundColor: Color = Color(red: 0.96, green: 0.55, blue: 0.24) // #F58D3D
|
||||
var foregroundColor: Color = Color(red: 1.0, green: 0.96, blue: 0.92) // #FFF5EB
|
||||
|
||||
// Stroke widths (scaled from SVG)
|
||||
private let houseStrokeWidth: CGFloat = 28
|
||||
private let checkmarkStrokeWidth: CGFloat = 32
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
let size = min(geometry.size.width, geometry.size.height)
|
||||
let scale = size / svgSize
|
||||
// Center offset to position content in middle of available space
|
||||
let offsetX = (geometry.size.width - size) / 2
|
||||
let offsetY = (geometry.size.height - size) / 2
|
||||
|
||||
ZStack {
|
||||
// Background
|
||||
if showBackground {
|
||||
BackgroundShape()
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color(red: 1.0, green: 0.64, blue: 0.28), // #FFA347
|
||||
Color(red: 0.96, green: 0.51, blue: 0.20) // #F58233
|
||||
],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
)
|
||||
.opacity(backgroundOpacity)
|
||||
.shadow(color: .black.opacity(0.15), radius: 12 * scale, x: 0, y: 8 * scale)
|
||||
}
|
||||
|
||||
// House left side
|
||||
HouseLeftPath(trimEnd: houseLeftProgress)
|
||||
.stroke(
|
||||
foregroundColor,
|
||||
style: StrokeStyle(
|
||||
lineWidth: houseStrokeWidth * scale,
|
||||
lineCap: .round,
|
||||
lineJoin: .round
|
||||
)
|
||||
)
|
||||
|
||||
// House right side
|
||||
HouseRightPath(trimEnd: houseRightProgress)
|
||||
.stroke(
|
||||
foregroundColor,
|
||||
style: StrokeStyle(
|
||||
lineWidth: houseStrokeWidth * scale,
|
||||
lineCap: .round,
|
||||
lineJoin: .round
|
||||
)
|
||||
)
|
||||
|
||||
// Window
|
||||
WindowShape(scale: windowScale)
|
||||
.fill(foregroundColor)
|
||||
|
||||
// Checkmark
|
||||
CheckmarkPath(trimEnd: checkmarkProgress)
|
||||
.stroke(
|
||||
foregroundColor,
|
||||
style: StrokeStyle(
|
||||
lineWidth: checkmarkStrokeWidth * scale,
|
||||
lineCap: .round,
|
||||
lineJoin: .round
|
||||
)
|
||||
)
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
.offset(x: offsetX, y: offsetY)
|
||||
}
|
||||
.aspectRatio(1, contentMode: .fit)
|
||||
}
|
||||
}
|
||||
|
||||
/// Animated version with built-in draw animation
|
||||
struct AnimatedMyCribIconView: View {
|
||||
@State private var houseLeftProgress: CGFloat = 0
|
||||
@State private var houseRightProgress: CGFloat = 0
|
||||
@State private var windowScale: CGFloat = 0
|
||||
@State private var checkmarkProgress: CGFloat = 0
|
||||
@State private var backgroundOpacity: Double = 0
|
||||
|
||||
var animationDuration: Double = 0.5
|
||||
var staggerDelay: Double = 0.15
|
||||
var showBackground: Bool = true
|
||||
|
||||
var body: some View {
|
||||
MyCribIconView(
|
||||
houseLeftProgress: houseLeftProgress,
|
||||
houseRightProgress: houseRightProgress,
|
||||
windowScale: windowScale,
|
||||
checkmarkProgress: checkmarkProgress,
|
||||
showBackground: showBackground,
|
||||
backgroundOpacity: backgroundOpacity
|
||||
)
|
||||
.onAppear {
|
||||
animateIn()
|
||||
}
|
||||
}
|
||||
|
||||
func animateIn() {
|
||||
// Background fade in
|
||||
withAnimation(.easeOut(duration: animationDuration * 0.5)) {
|
||||
backgroundOpacity = 1.0
|
||||
}
|
||||
|
||||
// House sides draw together
|
||||
withAnimation(.easeOut(duration: animationDuration).delay(staggerDelay)) {
|
||||
houseLeftProgress = 1.0
|
||||
houseRightProgress = 1.0
|
||||
}
|
||||
|
||||
// Window pops in
|
||||
withAnimation(.spring(response: 0.4, dampingFraction: 0.6).delay(staggerDelay * 2)) {
|
||||
windowScale = 1.0
|
||||
}
|
||||
|
||||
// Checkmark draws
|
||||
withAnimation(.easeOut(duration: animationDuration * 0.8).delay(staggerDelay * 3)) {
|
||||
checkmarkProgress = 1.0
|
||||
}
|
||||
}
|
||||
|
||||
func reset() {
|
||||
houseLeftProgress = 0
|
||||
houseRightProgress = 0
|
||||
windowScale = 0
|
||||
checkmarkProgress = 0
|
||||
backgroundOpacity = 0
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Static Icon") {
|
||||
MyCribIconView()
|
||||
.frame(width: 200, height: 200)
|
||||
.padding()
|
||||
}
|
||||
|
||||
#Preview("Animated Icon") {
|
||||
AnimatedMyCribIconView()
|
||||
.frame(width: 200, height: 200)
|
||||
.padding()
|
||||
}
|
||||
|
||||
#Preview("Icon Without Background") {
|
||||
MyCribIconView(showBackground: false)
|
||||
.frame(width: 200, height: 200)
|
||||
.padding()
|
||||
.background(Color.orange)
|
||||
}
|
||||
|
||||
#Preview("Custom Colors") {
|
||||
MyCribIconView(
|
||||
backgroundColor: .blue,
|
||||
foregroundColor: .white
|
||||
)
|
||||
.frame(width: 200, height: 200)
|
||||
.padding()
|
||||
}
|
||||
Reference in New Issue
Block a user