feat: theme manage and auth

This commit is contained in:
2026-06-12 15:42:09 +03:30
parent c3496e3de4
commit fdf15913a9
9 changed files with 407 additions and 40 deletions
@@ -5,6 +5,8 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.lifecycle.lifecycleScope
import com.approagency.pharmacy.data.local.SessionManager
import com.approagency.pharmacy.domain.repository.AuthRepository
@@ -23,7 +25,8 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
DrugTheme {
val themeMode by session.themeMode.collectAsState()
DrugTheme(themeMode = themeMode) {
AppNavGraph()
}
}
@@ -31,7 +31,21 @@ data class ProductDto(
@SerializedName("price") val price: Long? = null,
@SerializedName("type") val type: Int? = null,
@SerializedName("uuid") val uuid: String? = null,
@SerializedName("descriptions") val descriptions: String? = null
@SerializedName("descriptions") val descriptions: String? = null,
@SerializedName("expires_at") val expiresAt: String? = null,
@SerializedName("expire_at") val expireAt: String? = null,
@SerializedName("pivot") val pivot: ProductPivot? = null
) {
/** تاریخ انقضای اشتراکِ این کاربر؛ از pivot یا فیلدهای مستقیمِ محصول. */
val resolvedExpireAt: String?
get() = pivot?.expiresAt ?: pivot?.expireAt ?: expiresAt ?: expireAt
}
/** اطلاعات رابطه‌ی کاربر-محصول (شامل تاریخ انقضای خرید). */
data class ProductPivot(
@SerializedName("expires_at") val expiresAt: String? = null,
@SerializedName("expire_at") val expireAt: String? = null,
@SerializedName("created_at") val createdAt: String? = null
)
/**
@@ -55,6 +69,9 @@ data class StatusDto(
/** عنوان اشتراک جاری برای نمایش در نوار بالای اپ. */
val subscriptionTitle: String? get() = products?.firstOrNull()?.title
/** تاریخ انقضای اشتراک جاری. */
val subscriptionExpireAt: String? get() = products?.firstOrNull()?.resolvedExpireAt
val displayName: String? get() = fullName?.takeIf { it.isNotBlank() }
?: listOfNotNull(firstName, lastName).joinToString(" ").takeIf { it.isNotBlank() }
}
@@ -2,6 +2,7 @@ package com.approagency.pharmacy.data.local
import android.content.Context
import android.content.SharedPreferences
import com.approagency.pharmacy.ui.theme.ThemeMode
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -12,7 +13,8 @@ data class AccountState(
val mobile: String? = null,
val displayName: String? = null,
val isSubscribed: Boolean = false,
val subscriptionTitle: String? = null
val subscriptionTitle: String? = null,
val subscriptionExpireAt: String? = null
)
/**
@@ -75,13 +77,15 @@ class SessionManager(context: Context) {
mobile: String?,
displayName: String?,
isSubscribed: Boolean,
subscriptionTitle: String?
subscriptionTitle: String?,
subscriptionExpireAt: String?
) {
prefs.edit()
.putString(KEY_MOBILE, mobile)
.putString(KEY_DISPLAY_NAME, displayName)
.putBoolean(KEY_IS_SUBSCRIBED, isSubscribed)
.putString(KEY_SUB_TITLE, subscriptionTitle)
.putString(KEY_SUB_EXPIRE, subscriptionExpireAt)
.apply()
publishAccount()
}
@@ -101,6 +105,7 @@ class SessionManager(context: Context) {
.remove(KEY_DISPLAY_NAME)
.remove(KEY_IS_SUBSCRIBED)
.remove(KEY_SUB_TITLE)
.remove(KEY_SUB_EXPIRE)
.apply()
publishAccount()
}
@@ -109,12 +114,27 @@ class SessionManager(context: Context) {
_account.value = readAccount()
}
// ---------- حالت نمایش (تم) ----------
private val _themeMode = MutableStateFlow(readThemeMode())
val themeMode: StateFlow<ThemeMode> = _themeMode.asStateFlow()
fun setThemeMode(mode: ThemeMode) {
prefs.edit().putString(KEY_THEME_MODE, mode.name).apply()
_themeMode.value = mode
}
private fun readThemeMode(): ThemeMode =
runCatching { ThemeMode.valueOf(prefs.getString(KEY_THEME_MODE, null) ?: ThemeMode.SYSTEM.name) }
.getOrDefault(ThemeMode.SYSTEM)
private fun readAccount() = AccountState(
isLoggedIn = !token.isNullOrBlank(),
mobile = prefs.getString(KEY_MOBILE, null),
displayName = prefs.getString(KEY_DISPLAY_NAME, null),
isSubscribed = prefs.getBoolean(KEY_IS_SUBSCRIBED, false),
subscriptionTitle = prefs.getString(KEY_SUB_TITLE, null)
subscriptionTitle = prefs.getString(KEY_SUB_TITLE, null),
subscriptionExpireAt = prefs.getString(KEY_SUB_EXPIRE, null)
)
private companion object {
@@ -125,5 +145,7 @@ class SessionManager(context: Context) {
const val KEY_FREE_SEARCH_COUNT = "free_search_count"
const val KEY_IS_SUBSCRIBED = "is_subscribed"
const val KEY_SUB_TITLE = "subscription_title"
const val KEY_SUB_EXPIRE = "subscription_expire_at"
const val KEY_THEME_MODE = "theme_mode"
}
}
@@ -69,7 +69,8 @@ class AuthRepositoryImpl(
mobile = dto.mobile ?: session.account.value.mobile,
displayName = dto.displayName,
isSubscribed = dto.isSubscribed,
subscriptionTitle = dto.subscriptionTitle
subscriptionTitle = dto.subscriptionTitle,
subscriptionExpireAt = dto.subscriptionExpireAt
)
return dto.isSubscribed
}
@@ -5,24 +5,35 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ElevatedButton
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.navigation.NavHostController
import com.approagency.pharmacy.data.local.AccountState
import com.approagency.pharmacy.data.local.SessionManager
import com.approagency.pharmacy.domain.repository.AuthRepository
import com.approagency.pharmacy.presentation.account.AccountDrawer
import com.approagency.pharmacy.presentation.account.AccountSheet
import com.approagency.pharmacy.presentation.account.AccountSheetController
import kotlinx.coroutines.launch
import org.koin.compose.koinInject
@OptIn(ExperimentalMaterial3Api::class)
@@ -33,14 +44,44 @@ fun MainContainer(
) {
val session: SessionManager = koinInject()
val sheetController: AccountSheetController = koinInject()
val authRepository: AuthRepository = koinInject()
val account by session.account.collectAsState()
val sheetVisible by sheetController.visible.collectAsState()
val themeMode by session.themeMode.collectAsState()
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
val context = LocalContext.current
val appVersion = remember {
runCatching {
context.packageManager.getPackageInfo(context.packageName, 0).versionName
}.getOrNull().orEmpty()
}
ModalNavigationDrawer(
drawerState = drawerState,
drawerContent = {
AccountDrawer(
account = account,
appVersion = appVersion,
themeMode = themeMode,
onThemeModeChange = { session.setThemeMode(it) },
onLogout = {
scope.launch {
authRepository.logout()
drawerState.close()
}
}
)
}
) {
Scaffold(
topBar = {
AccountAppBar(
account = account,
onMenuClick = { scope.launch { drawerState.open() } },
onAccountClick = { sheetController.show() }
)
},
@@ -50,6 +91,7 @@ fun MainContainer(
content()
}
}
}
if (sheetVisible) {
AccountSheet(onDismiss = { sheetController.hide() })
@@ -60,9 +102,18 @@ fun MainContainer(
@Composable
private fun AccountAppBar(
account: AccountState,
onMenuClick: () -> Unit,
onAccountClick: () -> Unit
) {
TopAppBar(
navigationIcon = {
IconButton(onClick = onMenuClick) {
Icon(
imageVector = Icons.Filled.Menu,
contentDescription = "منو"
)
}
},
title = {
if (account.isLoggedIn) {
Column {
@@ -84,15 +135,7 @@ private fun AccountAppBar(
}
},
actions = {
if (account.isLoggedIn) {
IconButton(onClick = onAccountClick) {
Icon(
imageVector = Icons.Filled.AccountCircle,
contentDescription = "حساب کاربری",
tint = MaterialTheme.colorScheme.primary
)
}
} else {
if (!account.isLoggedIn) {
TextButton(onClick = onAccountClick) {
Text("ورود", textAlign = TextAlign.Center)
}
@@ -0,0 +1,265 @@
package com.approagency.pharmacy.presentation.account
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Logout
import androidx.compose.material.icons.filled.BrightnessAuto
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.DarkMode
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.LightMode
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Phone
import androidx.compose.material.icons.filled.WorkspacePremium
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import com.approagency.pharmacy.data.local.AccountState
import com.approagency.pharmacy.ui.theme.ThemeMode
import com.vada.caller.ui.theme.dime
/**
* کشوی کناری حساب کاربری با طراحی بهبودیافته:
* - سربرگ با آواتار، نام/موبایل و نشانِ وضعیت اشتراک
* - کارت اطلاعات (موبایل، اشتراک، تاریخ انقضا، نسخهی برنامه)
* - انتخاب حالت نمایش (سیستم/روشن/تاریک)
* - دکمهی خروج
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AccountDrawer(
account: AccountState,
appVersion: String,
themeMode: ThemeMode,
onThemeModeChange: (ThemeMode) -> Unit,
onLogout: () -> Unit
) {
ModalDrawerSheet(drawerContainerColor = MaterialTheme.colorScheme.surface) {
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
Column(
modifier = Modifier
.fillMaxHeight()
.fillMaxWidth()
.padding(horizontal = MaterialTheme.dime.lg, vertical = MaterialTheme.dime.xl)
) {
Header(account)
Spacer(Modifier.height(MaterialTheme.dime.xl))
// کارت اطلاعات حساب
Surface(
color = MaterialTheme.colorScheme.surfaceContainer,
shape = MaterialTheme.shapes.large,
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.padding(MaterialTheme.dime.md)) {
InfoRow(
icon = Icons.Filled.Phone,
label = "شماره موبایل",
value = account.mobile ?: "-"
)
InfoRow(
icon = Icons.Filled.WorkspacePremium,
label = "اشتراک",
value = account.subscriptionTitle ?: "بدون اشتراک",
highlight = account.isSubscribed
)
if (account.isSubscribed) {
InfoRow(
icon = Icons.Filled.CalendarMonth,
label = "تاریخ انقضا",
value = account.subscriptionExpireAt ?: "-"
)
}
InfoRow(
icon = Icons.Filled.Info,
label = "نسخه برنامه",
value = appVersion.ifBlank { "-" }
)
}
}
Spacer(Modifier.height(MaterialTheme.dime.xl))
// انتخاب حالت نمایش
Text(
text = "حالت نمایش",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold
)
Spacer(Modifier.height(MaterialTheme.dime.sm))
ThemeModeSelector(selected = themeMode, onSelect = onThemeModeChange)
Spacer(Modifier.weight(1f))
if (account.isLoggedIn) {
OutlinedButton(
onClick = onLogout,
modifier = Modifier.fillMaxWidth()
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Logout,
contentDescription = null,
tint = MaterialTheme.colorScheme.error
)
Spacer(Modifier.size(MaterialTheme.dime.sm))
Text(
text = "خروج از حساب",
color = MaterialTheme.colorScheme.error
)
}
}
}
}
}
}
@Composable
private fun Header(account: AccountState) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(56.dp)
.background(MaterialTheme.colorScheme.primaryContainer, CircleShape),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Filled.Person,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(32.dp)
)
}
Spacer(Modifier.size(MaterialTheme.dime.md))
Column(modifier = Modifier.weight(1f)) {
Text(
text = account.displayName?.takeIf { it.isNotBlank() }
?: account.mobile
?: "کاربر مهمان",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(Modifier.height(MaterialTheme.dime.xs))
SubscriptionBadge(isSubscribed = account.isSubscribed)
}
}
}
@Composable
private fun SubscriptionBadge(isSubscribed: Boolean) {
val container = if (isSubscribed)
MaterialTheme.colorScheme.primaryContainer
else
MaterialTheme.colorScheme.surfaceContainerHighest
val content = if (isSubscribed)
MaterialTheme.colorScheme.onPrimaryContainer
else
MaterialTheme.colorScheme.onSurfaceVariant
Surface(color = container, shape = CircleShape) {
Text(
text = if (isSubscribed) "اشتراک فعال" else "بدون اشتراک",
style = MaterialTheme.typography.labelSmall,
color = content,
modifier = Modifier.padding(
horizontal = MaterialTheme.dime.sm,
vertical = MaterialTheme.dime.xxs
)
)
}
}
@Composable
private fun InfoRow(
icon: ImageVector,
label: String,
value: String,
highlight: Boolean = false
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = MaterialTheme.dime.sm),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp)
)
Spacer(Modifier.size(MaterialTheme.dime.sm))
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f)
)
Text(
text = value,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = if (highlight)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.onSurface
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ThemeModeSelector(
selected: ThemeMode,
onSelect: (ThemeMode) -> Unit
) {
val options = listOf(
Triple(ThemeMode.SYSTEM, "سیستم", Icons.Filled.BrightnessAuto),
Triple(ThemeMode.LIGHT, "روشن", Icons.Filled.LightMode),
Triple(ThemeMode.DARK, "تاریک", Icons.Filled.DarkMode)
)
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
options.forEachIndexed { index, (mode, label, icon) ->
SegmentedButton(
selected = selected == mode,
onClick = { onSelect(mode) },
shape = SegmentedButtonDefaults.itemShape(index, options.size),
icon = {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
},
label = { Text(label, style = MaterialTheme.typography.labelMedium) }
)
}
}
}
@@ -2,6 +2,7 @@ package com.approagency.pharmacy.presentation.account
import android.app.Activity
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -29,6 +30,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import com.approagency.pharmacy.domain.model.SubscriptionProduct
@@ -77,17 +79,20 @@ fun AccountSheet(
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(MaterialTheme.dime.lg))
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
OutlinedTextField(
value = viewModel.mobile,
onValueChange = viewModel::updateMobile,
label = { Text("شماره موبایل") },
shape = MaterialTheme.shapes.large,
onValueChange = { viewModel.updateMobile(it) },
label = { Text("شماره موبایل" , textAlign = TextAlign.Right ) },
placeholder = { Text("09xxxxxxxxx") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
modifier = Modifier.fillMaxWidth()
)
}
Spacer(Modifier.height(MaterialTheme.dime.md))
PrimaryButton(text = "ارسال کد", isLoading = ui.busy, onClick = viewModel::sendOtp)
PrimaryButton(text = "ارسال کد", isLoading = ui.busy, onClick = { viewModel.sendOtp() })
}
AccountPhase.EnterOtp -> {
@@ -100,15 +105,15 @@ fun AccountSheet(
Spacer(Modifier.height(MaterialTheme.dime.lg))
OutlinedTextField(
value = viewModel.otp,
onValueChange = viewModel::updateOtp,
onValueChange = { viewModel.updateOtp(it) },
label = { Text("کد تأیید") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(MaterialTheme.dime.md))
PrimaryButton(text = "تأیید و ورود", isLoading = ui.busy, onClick = viewModel::verifyOtp)
TextButton(onClick = viewModel::editMobile, enabled = !ui.busy) {
PrimaryButton(text = "تأیید و ورود", isLoading = ui.busy, onClick = { viewModel.verifyOtp() })
TextButton(onClick = { viewModel.editMobile() }, enabled = !ui.busy) {
Text("ویرایش شماره موبایل")
}
}
@@ -123,7 +128,9 @@ fun AccountSheet(
Spacer(Modifier.height(MaterialTheme.dime.lg))
when {
ui.productsLoading -> Loading(
modifier = Modifier.fillMaxWidth().height(160.dp)
modifier = Modifier
.fillMaxWidth()
.height(160.dp)
)
else -> Column(
verticalArrangement = Arrangement.spacedBy(MaterialTheme.dime.md)
@@ -156,7 +163,7 @@ fun AccountSheet(
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(MaterialTheme.dime.lg))
TextButton(onClick = viewModel::logout) { Text("خروج از حساب") }
TextButton(onClick = { viewModel.logout() }) { Text("خروج از حساب") }
}
}
@@ -108,11 +108,16 @@ private val DarkColorScheme = darkColorScheme(
@Composable
fun DrugTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
themeMode: ThemeMode = ThemeMode.SYSTEM,
// Dynamic color is available on Android 12+
dynamicColor: Boolean = false,
content: @Composable () -> Unit
) {
val darkTheme = when (themeMode) {
ThemeMode.SYSTEM -> isSystemInDarkTheme()
ThemeMode.DARK -> true
ThemeMode.LIGHT -> false
}
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
@@ -0,0 +1,4 @@
package com.approagency.pharmacy.ui.theme
/** حالت نمایش انتخابی کاربر؛ پیش‌فرض پیروی از سیستم است. */
enum class ThemeMode { SYSTEM, LIGHT, DARK }