设置字体和应用图标

This commit is contained in:
zzh 2025-11-28 13:21:09 +08:00
parent 85af2fbc2c
commit 8c21431bcc
27 changed files with 1735 additions and 59 deletions

View File

@ -71,6 +71,9 @@ dependencies {
implementation("com.google.code.gson:gson:2.10.1")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.google.android.gms:play-services-location:21.0.1")
// ZXing 二维码生成
implementation("com.google.zxing:core:3.5.2")
debugImplementation("com.squareup.leakcanary:leakcanary-android:2.12")

View File

@ -20,6 +20,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.foundation.layout.Box
import androidx.compose.ui.unit.dp
import com.example.smarthome.ui.MainScaffold
import com.example.smarthome.ui.LoginScreen
import androidx.compose.ui.platform.ComposeView
import androidx.compose.foundation.Image
import androidx.compose.ui.res.painterResource
@ -29,6 +30,7 @@ import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.runtime.collectAsState
import com.example.smarthome.data.BackgroundManager
import com.example.smarthome.data.LanguageManager
import com.example.smarthome.data.UserManager
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
@ -36,6 +38,8 @@ class MainActivity : ComponentActivity() {
// 初始化语言管理器
LanguageManager.init(this)
// 初始化用户管理器
UserManager.init(this)
// 隐藏状态栏和导航栏
hideStatusBar()
@ -94,6 +98,26 @@ class MainActivity : ComponentActivity() {
@Composable
fun AppRoot() {
val context = androidx.compose.ui.platform.LocalContext.current
// 监听登录状态
val isLoggedIn by UserManager.isLoggedIn.collectAsState()
if (!isLoggedIn) {
// 未登录,显示登录页面
LoginScreen(
onLoginSuccess = {
// 登录成功后会自动更新isLoggedIn状态
}
)
} else {
// 已登录,显示主界面
MainContent()
}
}
@Composable
fun MainContent() {
val context = androidx.compose.ui.platform.LocalContext.current
val sharedPreferences = remember {
context.getSharedPreferences("smart_home_prefs", android.content.Context.MODE_PRIVATE)
}

View File

@ -130,6 +130,41 @@ object LanguageManager {
// 天气
"weather_humidity" to "湿度",
// 登录
"app_name" to "智能家居",
"app_slogan" to "让生活更智能",
"login_wechat_scan" to "微信扫码登录",
"login_wechat_hint" to "使用微信扫描二维码登录",
"login_waiting_scan" to "等待扫码...",
"login_scanned" to "扫码成功",
"login_confirm_on_phone" to "请在手机上确认登录",
"login_success" to "登录成功",
"login_simulate" to "模拟微信登录",
"login_other_methods" to "其他登录方式",
"login_phone" to "手机号",
"login_password" to "密码",
"login_agreement" to "登录即表示同意《用户协议》和《隐私政策》",
"login_refresh_qr" to "刷新二维码",
"login_wechat" to "微信登录",
"switch_to_phone" to "使用手机号登录",
// 手机号登录
"phone_login_title" to "手机号登录",
"phone_input_hint" to "输入手机号,快速登录/注册",
"phone_placeholder" to "请输入手机号",
"phone_invalid" to "请输入正确的11位手机号",
"phone_agreement" to "未注册的手机号将自动创建账号",
"get_verify_code" to "获取验证码",
"code_sent_to" to "验证码已发送至",
"change_phone" to "更换",
"code_invalid" to "请输入6位验证码",
"code_wrong" to "验证码错误,请重新输入",
"resend_code" to "重新发送",
"resend_code_in" to "重新发送",
"login_register" to "登录/注册",
"login_failed" to "登录失败,请重试",
"debug_code_hint" to "开发测试验证码:",
// 通用
"add_device" to "+ 添加设备",
"on" to "",
@ -203,6 +238,41 @@ object LanguageManager {
// Weather
"weather_humidity" to "Humidity",
// Login
"app_name" to "Smart Home",
"app_slogan" to "Make life smarter",
"login_wechat_scan" to "WeChat Scan Login",
"login_wechat_hint" to "Scan QR code with WeChat to login",
"login_waiting_scan" to "Waiting for scan...",
"login_scanned" to "Scanned",
"login_confirm_on_phone" to "Please confirm on your phone",
"login_success" to "Login successful",
"login_simulate" to "Simulate WeChat Login",
"login_other_methods" to "Other login methods",
"login_phone" to "Phone",
"login_password" to "Password",
"login_agreement" to "By logging in, you agree to our Terms and Privacy Policy",
"login_refresh_qr" to "Refresh QR Code",
"login_wechat" to "WeChat",
"switch_to_phone" to "Login with phone number",
// Phone Login
"phone_login_title" to "Phone Login",
"phone_input_hint" to "Enter phone number to login/register",
"phone_placeholder" to "Enter phone number",
"phone_invalid" to "Please enter a valid phone number",
"phone_agreement" to "Unregistered numbers will create a new account",
"get_verify_code" to "Get Code",
"code_sent_to" to "Code sent to",
"change_phone" to "Change",
"code_invalid" to "Please enter 6-digit code",
"code_wrong" to "Wrong code, please try again",
"resend_code" to "Resend",
"resend_code_in" to "Resend in",
"login_register" to "Login/Register",
"login_failed" to "Login failed, please try again",
"debug_code_hint" to "Debug code:",
// Common
"add_device" to "+ Add Device",
"on" to "On",

View File

@ -0,0 +1,278 @@
package com.example.smarthome.data
import android.content.Context
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import java.util.UUID
/**
* 用户管理器 - 管理用户登录状态和信息
* 支持手机号+验证码注册登录
*/
object UserManager {
private const val PREFS_NAME = "user_prefs"
private const val KEY_IS_LOGGED_IN = "is_logged_in"
private const val KEY_USER_ID = "user_id"
private const val KEY_PHONE = "phone"
private const val KEY_NICKNAME = "nickname"
private const val KEY_AVATAR_URL = "avatar_url"
private const val KEY_REGISTER_TIME = "register_time"
private const val KEY_LAST_LOGIN_TIME = "last_login_time"
private const val KEY_GENDER = "gender"
private const val KEY_BIRTHDAY = "birthday"
private const val KEY_EMAIL = "email"
private const val KEY_ADDRESS = "address"
// 用户数据库(模拟,实际应该用后端数据库)
private const val USERS_PREFS = "users_database"
data class UserInfo(
val userId: String = "",
val phone: String = "",
val nickname: String = "",
val avatarUrl: String = "",
val registerTime: Long = 0L,
val lastLoginTime: Long = 0L,
val gender: Int = 0,
val birthday: String = "",
val email: String = "",
val address: String = ""
)
private val _isLoggedIn = MutableStateFlow(false)
val isLoggedIn: StateFlow<Boolean> = _isLoggedIn
private val _userInfo = MutableStateFlow(UserInfo())
val userInfo: StateFlow<UserInfo> = _userInfo
// 验证码存储(模拟,实际应该用后端+短信服务)
private var currentVerifyCode: String = ""
private var currentVerifyPhone: String = ""
private var verifyCodeExpireTime: Long = 0L
fun init(context: Context) {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
_isLoggedIn.value = prefs.getBoolean(KEY_IS_LOGGED_IN, false)
if (_isLoggedIn.value) {
_userInfo.value = UserInfo(
userId = prefs.getString(KEY_USER_ID, "") ?: "",
phone = prefs.getString(KEY_PHONE, "") ?: "",
nickname = prefs.getString(KEY_NICKNAME, "") ?: "",
avatarUrl = prefs.getString(KEY_AVATAR_URL, "") ?: "",
registerTime = prefs.getLong(KEY_REGISTER_TIME, 0L),
lastLoginTime = prefs.getLong(KEY_LAST_LOGIN_TIME, 0L),
gender = prefs.getInt(KEY_GENDER, 0),
birthday = prefs.getString(KEY_BIRTHDAY, "") ?: "",
email = prefs.getString(KEY_EMAIL, "") ?: "",
address = prefs.getString(KEY_ADDRESS, "") ?: ""
)
}
}
/**
* 发送验证码模拟
* 实际项目中应该调用短信服务商API
* @return 验证码仅用于开发测试显示
*/
fun sendVerifyCode(phone: String): String {
// 生成6位随机验证码
currentVerifyCode = (100000..999999).random().toString()
currentVerifyPhone = phone
verifyCodeExpireTime = System.currentTimeMillis() + 5 * 60 * 1000 // 5分钟有效
// 实际项目中这里调用短信API发送验证码
// SmsService.send(phone, "您的验证码是: $currentVerifyCode")
return currentVerifyCode // 开发测试用,实际不应返回
}
/**
* 验证验证码
*/
fun verifyCode(phone: String, code: String): Boolean {
if (phone != currentVerifyPhone) return false
if (System.currentTimeMillis() > verifyCodeExpireTime) return false
return code == currentVerifyCode
}
/**
* 检查用户是否已注册
*/
fun isUserRegistered(context: Context, phone: String): Boolean {
val usersPrefs = context.getSharedPreferences(USERS_PREFS, Context.MODE_PRIVATE)
return usersPrefs.contains("user_$phone")
}
/**
* 手机号登录/注册
* 如果用户不存在则自动注册
*/
fun loginWithPhone(context: Context, phone: String): Result<UserInfo> {
val usersPrefs = context.getSharedPreferences(USERS_PREFS, Context.MODE_PRIVATE)
val currentTime = System.currentTimeMillis()
val userInfo: UserInfo
val isNewUser = !usersPrefs.contains("user_$phone")
if (isNewUser) {
// 新用户注册
val userId = UUID.randomUUID().toString()
userInfo = UserInfo(
userId = userId,
phone = phone,
nickname = "用户${phone.takeLast(4)}", // 默认昵称
avatarUrl = "",
registerTime = currentTime,
lastLoginTime = currentTime,
gender = 0,
birthday = "",
email = "",
address = ""
)
// 保存到用户数据库
usersPrefs.edit()
.putString("user_$phone", userId)
.putString("${userId}_phone", phone)
.putString("${userId}_nickname", userInfo.nickname)
.putString("${userId}_avatar", "")
.putLong("${userId}_register", currentTime)
.putLong("${userId}_lastlogin", currentTime)
.putInt("${userId}_gender", 0)
.putString("${userId}_birthday", "")
.putString("${userId}_email", "")
.putString("${userId}_address", "")
.apply()
} else {
// 已有用户登录
val userId = usersPrefs.getString("user_$phone", "") ?: ""
userInfo = UserInfo(
userId = userId,
phone = phone,
nickname = usersPrefs.getString("${userId}_nickname", "用户${phone.takeLast(4)}") ?: "",
avatarUrl = usersPrefs.getString("${userId}_avatar", "") ?: "",
registerTime = usersPrefs.getLong("${userId}_register", currentTime),
lastLoginTime = currentTime,
gender = usersPrefs.getInt("${userId}_gender", 0),
birthday = usersPrefs.getString("${userId}_birthday", "") ?: "",
email = usersPrefs.getString("${userId}_email", "") ?: "",
address = usersPrefs.getString("${userId}_address", "") ?: ""
)
// 更新最后登录时间
usersPrefs.edit()
.putLong("${userId}_lastlogin", currentTime)
.apply()
}
// 设置当前登录状态
_isLoggedIn.value = true
_userInfo.value = userInfo
// 保存当前登录用户
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.edit()
.putBoolean(KEY_IS_LOGGED_IN, true)
.putString(KEY_USER_ID, userInfo.userId)
.putString(KEY_PHONE, userInfo.phone)
.putString(KEY_NICKNAME, userInfo.nickname)
.putString(KEY_AVATAR_URL, userInfo.avatarUrl)
.putLong(KEY_REGISTER_TIME, userInfo.registerTime)
.putLong(KEY_LAST_LOGIN_TIME, userInfo.lastLoginTime)
.putInt(KEY_GENDER, userInfo.gender)
.putString(KEY_BIRTHDAY, userInfo.birthday)
.putString(KEY_EMAIL, userInfo.email)
.putString(KEY_ADDRESS, userInfo.address)
.apply()
return Result.success(userInfo)
}
/**
* 旧的登录方法兼容微信登录等
*/
fun login(context: Context, userId: String, nickname: String, avatarUrl: String) {
val currentTime = System.currentTimeMillis()
_isLoggedIn.value = true
_userInfo.value = UserInfo(
userId = userId,
nickname = nickname,
avatarUrl = avatarUrl,
lastLoginTime = currentTime
)
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.edit()
.putBoolean(KEY_IS_LOGGED_IN, true)
.putString(KEY_USER_ID, userId)
.putString(KEY_NICKNAME, nickname)
.putString(KEY_AVATAR_URL, avatarUrl)
.putLong(KEY_LAST_LOGIN_TIME, currentTime)
.apply()
}
fun logout(context: Context) {
_isLoggedIn.value = false
_userInfo.value = UserInfo()
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.edit()
.clear()
.apply()
}
/**
* 更新用户信息
*/
fun updateUserInfo(
context: Context,
nickname: String? = null,
avatarUrl: String? = null,
gender: Int? = null,
birthday: String? = null,
email: String? = null,
address: String? = null
) {
val current = _userInfo.value
val updated = current.copy(
nickname = nickname ?: current.nickname,
avatarUrl = avatarUrl ?: current.avatarUrl,
gender = gender ?: current.gender,
birthday = birthday ?: current.birthday,
email = email ?: current.email,
address = address ?: current.address
)
_userInfo.value = updated
// 保存到当前登录用户
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit()
nickname?.let { prefs.putString(KEY_NICKNAME, it) }
avatarUrl?.let { prefs.putString(KEY_AVATAR_URL, it) }
gender?.let { prefs.putInt(KEY_GENDER, it) }
birthday?.let { prefs.putString(KEY_BIRTHDAY, it) }
email?.let { prefs.putString(KEY_EMAIL, it) }
address?.let { prefs.putString(KEY_ADDRESS, it) }
prefs.apply()
// 同步到用户数据库
if (current.userId.isNotEmpty()) {
val usersPrefs = context.getSharedPreferences(USERS_PREFS, Context.MODE_PRIVATE).edit()
nickname?.let { usersPrefs.putString("${current.userId}_nickname", it) }
avatarUrl?.let { usersPrefs.putString("${current.userId}_avatar", it) }
gender?.let { usersPrefs.putInt("${current.userId}_gender", it) }
birthday?.let { usersPrefs.putString("${current.userId}_birthday", it) }
email?.let { usersPrefs.putString("${current.userId}_email", it) }
address?.let { usersPrefs.putString("${current.userId}_address", it) }
usersPrefs.apply()
}
}
fun updateAvatar(context: Context, avatarUrl: String) {
updateUserInfo(context, avatarUrl = avatarUrl)
}
fun updateNickname(context: Context, nickname: String) {
updateUserInfo(context, nickname = nickname)
}
}

View File

@ -0,0 +1,620 @@
package com.example.smarthome.ui
import android.graphics.Bitmap
import android.graphics.Color as AndroidColor
import androidx.compose.animation.core.*
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.example.smarthome.R
import com.example.smarthome.data.UserManager
import com.example.smarthome.data.tr
import com.google.zxing.BarcodeFormat
import com.google.zxing.qrcode.QRCodeWriter
import kotlinx.coroutines.delay
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URL
/**
* 登录配置 - 修改这里对接你的后端
*/
object LoginConfig {
// 后端API地址需要替换成你的实际地址
const val BASE_URL = "https://your-backend.com/api"
// 获取登录二维码的接口
const val GET_QR_CODE = "$BASE_URL/wechat/qrcode"
// 检查扫码状态的接口
const val CHECK_STATUS = "$BASE_URL/wechat/check"
// 轮询间隔(毫秒)
const val POLL_INTERVAL = 2000L
// 二维码过期时间(毫秒)
const val QR_EXPIRE_TIME = 300000L // 5分钟
// 是否使用模拟模式(开发测试用)
var USE_MOCK = true
}
@Composable
fun LoginScreen(
onLoginSuccess: () -> Unit
) {
val context = LocalContext.current
// 登录方式phone=手机号登录wechat=微信扫码登录
var loginMethod by remember { mutableStateOf("phone") }
// 扫码状态(微信登录用)
var scanStatus by remember { mutableStateOf(ScanStatus.WAITING) }
// 二维码内容
var qrCodeContent by remember { mutableStateOf("") }
var qrCodeBitmap by remember { mutableStateOf<Bitmap?>(null) }
var sessionId by remember { mutableStateOf("") }
var errorMessage by remember { mutableStateOf<String?>(null) }
// 获取二维码(仅微信登录时)
LaunchedEffect(loginMethod) {
if (loginMethod == "wechat") {
if (LoginConfig.USE_MOCK) {
qrCodeContent = "https://smarthome.example.com/login?session=${System.currentTimeMillis()}"
qrCodeBitmap = generateQRCode(qrCodeContent, 400)
} else {
try {
val response = fetchQRCode()
qrCodeContent = response.first
sessionId = response.second
qrCodeBitmap = generateQRCode(qrCodeContent, 400)
} catch (e: Exception) {
errorMessage = "获取二维码失败: ${e.message}"
}
}
}
}
// 轮询检查扫码状态
LaunchedEffect(sessionId, scanStatus, loginMethod) {
if (loginMethod == "wechat" && sessionId.isNotEmpty() && scanStatus == ScanStatus.WAITING && !LoginConfig.USE_MOCK) {
while (scanStatus == ScanStatus.WAITING) {
delay(LoginConfig.POLL_INTERVAL)
try {
val status = checkScanStatus(sessionId)
when (status.status) {
"scanned" -> scanStatus = ScanStatus.SCANNED
"confirmed" -> {
scanStatus = ScanStatus.CONFIRMED
UserManager.login(
context,
userId = status.userId,
nickname = status.nickname,
avatarUrl = status.avatarUrl
)
delay(500)
onLoginSuccess()
}
"expired" -> {
errorMessage = "二维码已过期,请刷新"
break
}
}
} catch (e: Exception) {
// 忽略网络错误,继续轮询
}
}
}
}
// 模拟扫码动画
val infiniteTransition = rememberInfiniteTransition(label = "scan")
val scanLineOffset by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(2000, easing = LinearEasing),
repeatMode = RepeatMode.Restart
),
label = "scan_line"
)
// 光晕动画
val glowAlpha by infiniteTransition.animateFloat(
initialValue = 0.3f,
targetValue = 0.7f,
animationSpec = infiniteRepeatable(
animation = tween(1500, easing = EaseInOut),
repeatMode = RepeatMode.Reverse
),
label = "glow"
)
Box(
modifier = Modifier.fillMaxSize()
) {
// 背景
Image(
painter = painterResource(id = R.drawable.background2),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
// 模糊遮罩
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.3f))
)
// 主内容
Column(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
// Logo和标题
Box(
modifier = Modifier
.size(80.dp)
.shadow(16.dp, CircleShape)
.clip(CircleShape)
.background(
Brush.linearGradient(
listOf(Color(0xFFA9F0FF), Color(0xFFB89CFF))
)
),
contentAlignment = Alignment.Center
) {
Text(
text = "🏠",
fontSize = 36.sp
)
}
Spacer(modifier = Modifier.height(16.dp))
Text(
text = tr("app_name"),
fontSize = 28.sp,
fontWeight = FontWeight.Bold,
color = Color.White
)
Text(
text = tr("app_slogan"),
fontSize = 14.sp,
color = Color.White.copy(alpha = 0.7f)
)
Spacer(modifier = Modifier.height(32.dp))
// 根据登录方式显示不同内容
if (loginMethod == "phone") {
// 手机号登录
PhoneLoginScreen(
onLoginSuccess = onLoginSuccess,
onBack = { /* 无处可返回 */ }
)
} else {
// 微信扫码登录卡片
WeChatLoginCard(
qrCodeBitmap = qrCodeBitmap,
errorMessage = errorMessage,
scanStatus = scanStatus,
scanLineOffset = scanLineOffset,
glowAlpha = glowAlpha,
onRefreshQR = {
qrCodeBitmap = null
errorMessage = null
scanStatus = ScanStatus.WAITING
qrCodeContent = "https://smarthome.example.com/login?session=${System.currentTimeMillis()}"
qrCodeBitmap = generateQRCode(qrCodeContent, 400)
},
onSimulateLogin = {
scanStatus = ScanStatus.SCANNED
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
scanStatus = ScanStatus.CONFIRMED
UserManager.login(
context,
userId = "wechat_user_001",
nickname = "微信用户",
avatarUrl = ""
)
onLoginSuccess()
}, 1000)
},
onSwitchToPhone = { loginMethod = "phone" }
)
}
Spacer(modifier = Modifier.height(24.dp))
// 切换登录方式
if (loginMethod == "phone") {
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = tr("login_other_methods"),
fontSize = 12.sp,
color = Color.White.copy(alpha = 0.5f)
)
}
Spacer(modifier = Modifier.height(12.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(24.dp)
) {
// 微信登录
LoginMethodButton(
icon = "💬",
label = tr("login_wechat"),
onClick = { loginMethod = "wechat" }
)
}
}
Spacer(modifier = Modifier.height(24.dp))
// 底部协议
Text(
text = tr("login_agreement"),
fontSize = 11.sp,
color = Color.White.copy(alpha = 0.4f),
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 32.dp)
)
}
}
}
@Composable
fun WeChatLoginCard(
qrCodeBitmap: Bitmap?,
errorMessage: String?,
scanStatus: ScanStatus,
scanLineOffset: Float,
glowAlpha: Float,
onRefreshQR: () -> Unit,
onSimulateLogin: () -> Unit,
onSwitchToPhone: () -> Unit
) {
Box(
modifier = Modifier
.width(360.dp)
.shadow(24.dp, RoundedCornerShape(28.dp))
.clip(RoundedCornerShape(28.dp))
.background(
Brush.verticalGradient(
listOf(
Color.White.copy(alpha = 0.15f),
Color.White.copy(alpha = 0.05f)
)
)
)
.border(
width = 1.dp,
brush = Brush.verticalGradient(
listOf(
Color.White.copy(alpha = 0.3f),
Color.White.copy(alpha = 0.1f)
)
),
shape = RoundedCornerShape(28.dp)
)
.padding(32.dp)
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = tr("login_wechat_scan"),
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White
)
Text(
text = tr("login_wechat_hint"),
fontSize = 13.sp,
color = Color.White.copy(alpha = 0.6f),
modifier = Modifier.padding(top = 4.dp)
)
Spacer(modifier = Modifier.height(24.dp))
// 二维码区域
Box(
modifier = Modifier
.size(200.dp)
.clip(RoundedCornerShape(16.dp))
.background(Color.White)
.padding(12.dp),
contentAlignment = Alignment.Center
) {
if (qrCodeBitmap != null) {
Image(
bitmap = qrCodeBitmap.asImageBitmap(),
contentDescription = "Login QR Code",
modifier = Modifier.fillMaxSize()
)
} else if (errorMessage != null) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(text = "⚠️", fontSize = 32.sp)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = errorMessage,
fontSize = 12.sp,
color = Color.Red,
textAlign = TextAlign.Center
)
}
} else {
CircularProgressIndicator(
modifier = Modifier.size(48.dp),
color = Color(0xFF07C160)
)
}
// 扫描线动画
if (scanStatus == ScanStatus.WAITING && qrCodeBitmap != null) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(3.dp)
.offset(y = (scanLineOffset * 176 - 88).dp)
.background(
Brush.horizontalGradient(
listOf(
Color.Transparent,
Color(0xFF07C160),
Color.Transparent
)
)
)
)
}
// 扫码成功遮罩
if (scanStatus == ScanStatus.SCANNED || scanStatus == ScanStatus.CONFIRMED) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.White.copy(alpha = 0.9f)),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(text = "", fontSize = 48.sp, color = Color(0xFF07C160))
Text(
text = if (scanStatus == ScanStatus.CONFIRMED) tr("login_success") else tr("login_scanned"),
fontSize = 14.sp,
color = Color(0xFF07C160),
fontWeight = FontWeight.Medium
)
}
}
}
}
Spacer(modifier = Modifier.height(16.dp))
// 状态提示
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Box(
modifier = Modifier
.size(8.dp)
.clip(CircleShape)
.background(Color(0xFF07C160))
.alpha(glowAlpha)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = when (scanStatus) {
ScanStatus.WAITING -> tr("login_waiting_scan")
ScanStatus.SCANNED -> tr("login_confirm_on_phone")
ScanStatus.CONFIRMED -> tr("login_success")
},
fontSize = 13.sp,
color = Color.White.copy(alpha = 0.7f)
)
}
Spacer(modifier = Modifier.height(24.dp))
// 模拟登录按钮(仅在模拟模式下显示)
if (LoginConfig.USE_MOCK) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
.clip(RoundedCornerShape(12.dp))
.background(
Brush.horizontalGradient(
listOf(Color(0xFF07C160), Color(0xFF06AD56))
)
)
.clickable { onSimulateLogin() },
contentAlignment = Alignment.Center
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(text = "💬", fontSize = 18.sp)
Text(
text = tr("login_simulate"),
fontSize = 15.sp,
fontWeight = FontWeight.Medium,
color = Color.White
)
}
}
Spacer(modifier = Modifier.height(16.dp))
}
// 刷新二维码
Text(
text = tr("login_refresh_qr"),
fontSize = 13.sp,
color = Color(0xFF07C160),
modifier = Modifier.clickable { onRefreshQR() }
)
Spacer(modifier = Modifier.height(16.dp))
// 切换到手机号登录
Text(
text = tr("switch_to_phone"),
fontSize = 13.sp,
color = Color.White.copy(alpha = 0.6f),
modifier = Modifier.clickable { onSwitchToPhone() }
)
}
}
}
/**
* 生成二维码图片
*/
fun generateQRCode(content: String, size: Int): Bitmap? {
return try {
val writer = QRCodeWriter()
val bitMatrix = writer.encode(content, BarcodeFormat.QR_CODE, size, size)
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.RGB_565)
for (x in 0 until size) {
for (y in 0 until size) {
bitmap.setPixel(x, y, if (bitMatrix[x, y]) AndroidColor.BLACK else AndroidColor.WHITE)
}
}
bitmap
} catch (e: Exception) {
null
}
}
/**
* 从后端获取登录二维码
* 返回: Pair<二维码内容URL, sessionId>
*/
suspend fun fetchQRCode(): Pair<String, String> = withContext(Dispatchers.IO) {
val url = URL(LoginConfig.GET_QR_CODE)
val connection = url.openConnection() as HttpURLConnection
connection.requestMethod = "GET"
connection.connectTimeout = 10000
connection.readTimeout = 10000
val response = connection.inputStream.bufferedReader().readText()
val json = JSONObject(response)
Pair(
json.getString("qrCodeUrl"),
json.getString("sessionId")
)
}
/**
* 检查扫码状态
*/
data class ScanStatusResult(
val status: String, // waiting, scanned, confirmed, expired
val userId: String = "",
val nickname: String = "",
val avatarUrl: String = ""
)
suspend fun checkScanStatus(sessionId: String): ScanStatusResult = withContext(Dispatchers.IO) {
val url = URL("${LoginConfig.CHECK_STATUS}?sessionId=$sessionId")
val connection = url.openConnection() as HttpURLConnection
connection.requestMethod = "GET"
connection.connectTimeout = 10000
connection.readTimeout = 10000
val response = connection.inputStream.bufferedReader().readText()
val json = JSONObject(response)
ScanStatusResult(
status = json.getString("status"),
userId = json.optString("userId", ""),
nickname = json.optString("nickname", ""),
avatarUrl = json.optString("avatarUrl", "")
)
}
@Composable
fun LoginMethodButton(
icon: String,
label: String,
onClick: () -> Unit
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) { onClick() }
) {
Box(
modifier = Modifier
.size(44.dp)
.clip(CircleShape)
.background(Color.White.copy(alpha = 0.1f))
.border(1.dp, Color.White.copy(alpha = 0.2f), CircleShape),
contentAlignment = Alignment.Center
) {
Text(text = icon, fontSize = 20.sp)
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = label,
fontSize = 11.sp,
color = Color.White.copy(alpha = 0.6f)
)
}
}
enum class ScanStatus {
WAITING, // 等待扫码
SCANNED, // 已扫码,等待确认
CONFIRMED // 已确认登录
}

View File

@ -635,6 +635,9 @@ fun TopBar() {
var weatherInfo by remember { mutableStateOf(WeatherService.getSimulatedWeather()) }
var isLoading by remember { mutableStateOf(true) }
// 获取用户信息
val userInfo by com.example.smarthome.data.UserManager.userInfo.collectAsState()
LaunchedEffect(Unit) {
try {
weatherInfo = WeatherService.getWeather(context)
@ -646,7 +649,72 @@ fun TopBar() {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
Text(text = tr("dashboard_title"), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = Color.White)
WeatherCard(weatherInfo = weatherInfo, isLoading = isLoading)
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
WeatherCard(weatherInfo = weatherInfo, isLoading = isLoading)
// 用户头像
UserAvatar(
nickname = userInfo.nickname,
avatarUrl = userInfo.avatarUrl,
onClick = { /* 点击头像可以打开用户菜单 */ }
)
}
}
}
@Composable
fun UserAvatar(
nickname: String,
avatarUrl: String,
onClick: () -> Unit
) {
val context = androidx.compose.ui.platform.LocalContext.current
Box(
modifier = Modifier
.size(40.dp)
.shadow(8.dp, androidx.compose.foundation.shape.CircleShape)
.clip(androidx.compose.foundation.shape.CircleShape)
.background(
Brush.linearGradient(
listOf(Color(0xFFA9F0FF), Color(0xFFB89CFF))
)
)
.border(
width = 2.dp,
brush = Brush.linearGradient(
listOf(Color.White.copy(alpha = 0.5f), Color.White.copy(alpha = 0.2f))
),
shape = androidx.compose.foundation.shape.CircleShape
)
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) { onClick() },
contentAlignment = Alignment.Center
) {
if (avatarUrl.isNotEmpty()) {
// 如果有头像URL可以用Coil加载网络图片
// 这里暂时用首字母代替
Text(
text = nickname.firstOrNull()?.uppercase() ?: "U",
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
color = Color.White
)
} else {
// 显示昵称首字母
Text(
text = nickname.firstOrNull()?.uppercase() ?: "U",
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
color = Color.White
)
}
}
}
@ -1135,7 +1203,14 @@ fun AirConditionerCard(modifier: Modifier = Modifier, roomName: String = "房间
"💧" to tr("ac_mode_dry"),
"🌀" to tr("ac_mode_fan")
)
val fanSpeeds = listOf(tr("ac_fan_auto"), tr("ac_fan_low"), tr("ac_fan_medium"), tr("ac_fan_high"))
// 风速旋转速度(根据档位变化)
val fanRotationDuration = when (fanSpeed) {
0 -> 2000 // 自动
1 -> 3000 // 低速
2 -> 1500 // 中速
else -> 800 // 高速
}
// 图标动画
val infiniteTransition = rememberInfiniteTransition(label = "ac_icon")
@ -1162,6 +1237,25 @@ fun AirConditionerCard(modifier: Modifier = Modifier, roomName: String = "房间
label = "drop_offset"
)
// 风扇旋转动画
val fanRotation by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 360f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = fanRotationDuration, easing = LinearEasing),
repeatMode = RepeatMode.Restart
),
label = "fan_rotation"
)
// 风速标签
val fanSpeedLabel = when (fanSpeed) {
0 -> tr("ac_fan_auto")
1 -> tr("ac_fan_low")
2 -> tr("ac_fan_medium")
else -> tr("ac_fan_high")
}
// 根据模式决定主题色
val accentColor = when (selectedMode) {
0 -> Color(0xFF4FC3F7) // 制冷 - 蓝色
@ -1257,29 +1351,66 @@ fun AirConditionerCard(modifier: Modifier = Modifier, roomName: String = "房间
)
}
// 温度调节按钮
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
// 升温按钮
Box(
// 风速按钮 + 温度调节按钮
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
// 风速按钮(点击循环切换)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.size(36.dp)
.clip(RoundedCornerShape(10.dp))
.background(if (isOn) accentColor.copy(alpha = 0.3f) else Color(0xFF3A3A4E))
.clickable(enabled = isOn && temp < 32f) { temp += 1f },
contentAlignment = Alignment.Center
.clip(RoundedCornerShape(12.dp))
.background(if (isOn) accentColor.copy(alpha = 0.2f) else Color(0xFF3A3A4E))
.clickable(enabled = isOn) { fanSpeed = (fanSpeed + 1) % 4 }
.padding(horizontal = 12.dp, vertical = 8.dp)
) {
Text(text = "", color = if (isOn) Color.White else Color.Gray, fontSize = 14.sp)
// 风扇图标(根据风速显示不同图片,带旋转动画)
val fanIconRes = when (fanSpeed) {
1 -> R.drawable.fan_low // 低速 - 3叶片
2 -> R.drawable.fan_medium // 中速 - 4叶片
3 -> R.drawable.fan_high // 高速 - 5叶片
else -> R.drawable.fan_medium // 自动 - 默认4叶片
}
Image(
painter = painterResource(id = fanIconRes),
contentDescription = "Fan Speed",
modifier = Modifier
.size(24.dp)
.then(if (isOn) Modifier.rotate(fanRotation) else Modifier)
)
Text(
text = fanSpeedLabel,
fontSize = 10.sp,
color = if (isOn) Color.White else Color.Gray,
maxLines = 1
)
}
// 降温按钮
Box(
modifier = Modifier
.size(36.dp)
.clip(RoundedCornerShape(10.dp))
.background(if (isOn) accentColor.copy(alpha = 0.3f) else Color(0xFF3A3A4E))
.clickable(enabled = isOn && temp > 16f) { temp -= 1f },
contentAlignment = Alignment.Center
) {
Text(text = "", color = if (isOn) Color.White else Color.Gray, fontSize = 14.sp)
// 温度调节按钮
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
// 升温按钮
Box(
modifier = Modifier
.size(36.dp)
.clip(RoundedCornerShape(10.dp))
.background(if (isOn) accentColor.copy(alpha = 0.3f) else Color(0xFF3A3A4E))
.clickable(enabled = isOn && temp < 32f) { temp += 1f },
contentAlignment = Alignment.Center
) {
Text(text = "", color = if (isOn) Color.White else Color.Gray, fontSize = 14.sp)
}
// 降温按钮
Box(
modifier = Modifier
.size(36.dp)
.clip(RoundedCornerShape(10.dp))
.background(if (isOn) accentColor.copy(alpha = 0.3f) else Color(0xFF3A3A4E))
.clickable(enabled = isOn && temp > 16f) { temp -= 1f },
contentAlignment = Alignment.Center
) {
Text(text = "", color = if (isOn) Color.White else Color.Gray, fontSize = 14.sp)
}
}
}
}
@ -1300,13 +1431,14 @@ fun AirConditionerCard(modifier: Modifier = Modifier, roomName: String = "房间
Box(
modifier = Modifier
.weight(1f)
.height(44.dp)
.heightIn(min = 44.dp)
.clip(RoundedCornerShape(12.dp))
.background(
if (isSelected && isOn) modeColor.copy(alpha = 0.4f)
else Color(0xFF2A2A3E).copy(alpha = 0.6f)
)
.clickable(enabled = isOn) { selectedMode = index },
.clickable(enabled = isOn) { selectedMode = index }
.padding(vertical = 4.dp),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
@ -1314,42 +1446,13 @@ fun AirConditionerCard(modifier: Modifier = Modifier, roomName: String = "房间
Text(
text = name,
fontSize = 10.sp,
color = if (isSelected && isOn) Color.White else Color(0xFF9AA0A6)
color = if (isSelected && isOn) Color.White else Color(0xFF9AA0A6),
maxLines = 1
)
}
}
}
}
// 风速选择
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(text = tr("ac_fan_speed"), fontSize = 12.sp, color = Color(0xFF9AA0A6))
fanSpeeds.forEachIndexed { index, speed ->
val isSelected = fanSpeed == index
Box(
modifier = Modifier
.weight(1f)
.height(28.dp)
.clip(RoundedCornerShape(8.dp))
.background(
if (isSelected && isOn) accentColor.copy(alpha = 0.5f)
else Color(0xFF2A2A3E).copy(alpha = 0.4f)
)
.clickable(enabled = isOn) { fanSpeed = index },
contentAlignment = Alignment.Center
) {
Text(
text = speed,
fontSize = 11.sp,
color = if (isSelected && isOn) Color.White else Color(0xFF9AA0A6)
)
}
}
}
}
}
}

View File

@ -0,0 +1,562 @@
package com.example.smarthome.ui
import androidx.compose.animation.core.*
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextStyle
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.dp
import androidx.compose.ui.unit.sp
import com.example.smarthome.data.UserManager
import com.example.smarthome.data.tr
import kotlinx.coroutines.delay
/**
* 手机号登录界面
*/
@Composable
fun PhoneLoginScreen(
onLoginSuccess: () -> Unit,
onBack: () -> Unit
) {
val context = LocalContext.current
// 登录步骤0=输入手机号1=输入验证码
var step by remember { mutableStateOf(0) }
// 手机号
var phoneNumber by remember { mutableStateOf("") }
// 验证码
var verifyCode by remember { mutableStateOf("") }
// 倒计时
var countdown by remember { mutableStateOf(0) }
// 错误信息
var errorMessage by remember { mutableStateOf<String?>(null) }
// 加载状态
var isLoading by remember { mutableStateOf(false) }
// 开发测试:显示验证码
var debugCode by remember { mutableStateOf("") }
// 倒计时
LaunchedEffect(countdown) {
if (countdown > 0) {
delay(1000)
countdown--
}
}
Box(
modifier = Modifier
.width(360.dp)
) {
// 玻璃态卡片
Box(
modifier = Modifier
.fillMaxWidth()
.shadow(24.dp, RoundedCornerShape(28.dp))
.clip(RoundedCornerShape(28.dp))
.background(
Brush.verticalGradient(
listOf(
Color.White.copy(alpha = 0.15f),
Color.White.copy(alpha = 0.05f)
)
)
)
.border(
width = 1.dp,
brush = Brush.verticalGradient(
listOf(
Color.White.copy(alpha = 0.3f),
Color.White.copy(alpha = 0.1f)
)
),
shape = RoundedCornerShape(28.dp)
)
.padding(28.dp)
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
// 返回按钮和标题
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(36.dp)
.clip(CircleShape)
.background(Color.White.copy(alpha = 0.1f))
.clickable { onBack() },
contentAlignment = Alignment.Center
) {
Text(text = "", fontSize = 18.sp, color = Color.White)
}
Spacer(modifier = Modifier.weight(1f))
Text(
text = tr("phone_login_title"),
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
color = Color.White
)
Spacer(modifier = Modifier.weight(1f))
Spacer(modifier = Modifier.width(36.dp))
}
Spacer(modifier = Modifier.height(32.dp))
if (step == 0) {
// 步骤1输入手机号
PhoneInputStep(
phoneNumber = phoneNumber,
onPhoneChange = {
phoneNumber = it.filter { c -> c.isDigit() }.take(11)
errorMessage = null
},
errorMessage = errorMessage,
isLoading = isLoading,
onSendCode = {
if (phoneNumber.length != 11) {
errorMessage = tr("phone_invalid")
} else {
isLoading = true
// 发送验证码
debugCode = UserManager.sendVerifyCode(phoneNumber)
isLoading = false
countdown = 60
step = 1
}
}
)
} else {
// 步骤2输入验证码
VerifyCodeStep(
phoneNumber = phoneNumber,
verifyCode = verifyCode,
onCodeChange = {
verifyCode = it.filter { c -> c.isDigit() }.take(6)
errorMessage = null
},
countdown = countdown,
errorMessage = errorMessage,
isLoading = isLoading,
debugCode = debugCode,
onResendCode = {
if (countdown == 0) {
debugCode = UserManager.sendVerifyCode(phoneNumber)
countdown = 60
}
},
onVerify = {
if (verifyCode.length != 6) {
errorMessage = tr("code_invalid")
} else if (!UserManager.verifyCode(phoneNumber, verifyCode)) {
errorMessage = tr("code_wrong")
} else {
isLoading = true
// 登录/注册
val result = UserManager.loginWithPhone(context, phoneNumber)
isLoading = false
if (result.isSuccess) {
onLoginSuccess()
} else {
errorMessage = tr("login_failed")
}
}
},
onChangePhone = {
step = 0
verifyCode = ""
errorMessage = null
}
)
}
}
}
}
}
@Composable
fun PhoneInputStep(
phoneNumber: String,
onPhoneChange: (String) -> Unit,
errorMessage: String?,
isLoading: Boolean,
onSendCode: () -> Unit
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = tr("phone_input_hint"),
fontSize = 14.sp,
color = Color.White.copy(alpha = 0.7f),
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(24.dp))
// 手机号输入框
Box(
modifier = Modifier
.fillMaxWidth()
.height(56.dp)
.clip(RoundedCornerShape(16.dp))
.background(Color.White.copy(alpha = 0.1f))
.border(
width = 1.dp,
color = if (errorMessage != null) Color.Red.copy(alpha = 0.5f)
else Color.White.copy(alpha = 0.2f),
shape = RoundedCornerShape(16.dp)
)
.padding(horizontal = 16.dp),
contentAlignment = Alignment.CenterStart
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "+86",
fontSize = 16.sp,
color = Color.White.copy(alpha = 0.6f)
)
Spacer(modifier = Modifier.width(8.dp))
Box(
modifier = Modifier
.width(1.dp)
.height(24.dp)
.background(Color.White.copy(alpha = 0.2f))
)
Spacer(modifier = Modifier.width(12.dp))
BasicTextField(
value = phoneNumber,
onValueChange = onPhoneChange,
textStyle = TextStyle(
fontSize = 18.sp,
color = Color.White,
letterSpacing = 2.sp
),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
singleLine = true,
cursorBrush = SolidColor(Color.White),
modifier = Modifier.weight(1f),
decorationBox = { innerTextField ->
Box {
if (phoneNumber.isEmpty()) {
Text(
text = tr("phone_placeholder"),
fontSize = 16.sp,
color = Color.White.copy(alpha = 0.3f)
)
}
innerTextField()
}
}
)
}
}
// 错误信息
if (errorMessage != null) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = errorMessage,
fontSize = 12.sp,
color = Color(0xFFFF6B6B)
)
}
Spacer(modifier = Modifier.height(24.dp))
// 获取验证码按钮
Box(
modifier = Modifier
.fillMaxWidth()
.height(52.dp)
.clip(RoundedCornerShape(14.dp))
.background(
if (phoneNumber.length == 11 && !isLoading)
Brush.horizontalGradient(
listOf(Color(0xFF667EEA), Color(0xFF764BA2))
)
else
Brush.horizontalGradient(
listOf(Color(0xFF4A4A5A), Color(0xFF3A3A4A))
)
)
.clickable(enabled = phoneNumber.length == 11 && !isLoading) { onSendCode() },
contentAlignment = Alignment.Center
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = Color.White,
strokeWidth = 2.dp
)
} else {
Text(
text = tr("get_verify_code"),
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = Color.White
)
}
}
Spacer(modifier = Modifier.height(16.dp))
// 协议提示
Text(
text = tr("phone_agreement"),
fontSize = 11.sp,
color = Color.White.copy(alpha = 0.4f),
textAlign = TextAlign.Center
)
}
}
@Composable
fun VerifyCodeStep(
phoneNumber: String,
verifyCode: String,
onCodeChange: (String) -> Unit,
countdown: Int,
errorMessage: String?,
isLoading: Boolean,
debugCode: String,
onResendCode: () -> Unit,
onVerify: () -> Unit,
onChangePhone: () -> Unit
) {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = tr("code_sent_to"),
fontSize = 14.sp,
color = Color.White.copy(alpha = 0.7f)
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Text(
text = "+86 ${phoneNumber.take(3)}****${phoneNumber.takeLast(4)}",
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = Color.White
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = tr("change_phone"),
fontSize = 13.sp,
color = Color(0xFF667EEA),
modifier = Modifier.clickable { onChangePhone() }
)
}
Spacer(modifier = Modifier.height(24.dp))
// 验证码输入框6位数字
Box(
modifier = Modifier
.fillMaxWidth()
.clickable { focusRequester.requestFocus() }
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth()
) {
repeat(6) { index ->
val char = verifyCode.getOrNull(index)?.toString() ?: ""
val isFocused = verifyCode.length == index
Box(
modifier = Modifier
.weight(1f)
.aspectRatio(1f)
.clip(RoundedCornerShape(12.dp))
.background(Color.White.copy(alpha = if (isFocused) 0.15f else 0.1f))
.border(
width = if (isFocused) 2.dp else 1.dp,
color = if (errorMessage != null) Color.Red.copy(alpha = 0.5f)
else if (isFocused) Color(0xFF667EEA)
else Color.White.copy(alpha = 0.2f),
shape = RoundedCornerShape(12.dp)
),
contentAlignment = Alignment.Center
) {
Text(
text = char,
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
color = Color.White
)
}
}
}
// 隐藏的输入框 - 覆盖整个区域
BasicTextField(
value = verifyCode,
onValueChange = onCodeChange,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier
.matchParentSize()
.focusRequester(focusRequester)
.alpha(0f),
textStyle = TextStyle(color = Color.Transparent)
)
}
// 错误信息
if (errorMessage != null) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = errorMessage,
fontSize = 12.sp,
color = Color(0xFFFF6B6B)
)
}
Spacer(modifier = Modifier.height(16.dp))
// 开发测试:显示验证码
if (debugCode.isNotEmpty()) {
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(Color(0xFF2A2A3E))
.padding(12.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "🔧",
fontSize = 14.sp
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = tr("debug_code_hint"),
fontSize = 12.sp,
color = Color.White.copy(alpha = 0.6f)
)
Spacer(modifier = Modifier.weight(1f))
Text(
text = debugCode,
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
color = Color(0xFF667EEA),
letterSpacing = 4.sp
)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
// 重新发送
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth()
) {
if (countdown > 0) {
Text(
text = "${tr("resend_code_in")} ${countdown}s",
fontSize = 13.sp,
color = Color.White.copy(alpha = 0.5f)
)
} else {
Text(
text = tr("resend_code"),
fontSize = 13.sp,
color = Color(0xFF667EEA),
modifier = Modifier.clickable { onResendCode() }
)
}
}
Spacer(modifier = Modifier.height(24.dp))
// 登录按钮
Box(
modifier = Modifier
.fillMaxWidth()
.height(52.dp)
.clip(RoundedCornerShape(14.dp))
.background(
if (verifyCode.length == 6 && !isLoading)
Brush.horizontalGradient(
listOf(Color(0xFF667EEA), Color(0xFF764BA2))
)
else
Brush.horizontalGradient(
listOf(Color(0xFF4A4A5A), Color(0xFF3A3A4A))
)
)
.clickable(enabled = verifyCode.length == 6 && !isLoading) { onVerify() },
contentAlignment = Alignment.Center
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = Color.White,
strokeWidth = 2.dp
)
} else {
Text(
text = tr("login_register"),
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = Color.White
)
}
}
}
}

View File

@ -146,7 +146,9 @@ fun SettingsContentList() {
item {
Spacer(modifier = Modifier.height(16.dp))
LogoutButton(onClick = { })
LogoutButton(onClick = {
com.example.smarthome.data.UserManager.logout(context)
})
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -4,7 +4,7 @@
android:viewportWidth="108"
android:viewportHeight="108">
<!-- 渐变背景 -->
<!-- 渐变背景 - 匹配图标的绿蓝渐变 -->
<path
android:pathData="M0,0 h108 v108 h-108 z">
<aapt:attr name="android:fillColor" xmlns:aapt="http://schemas.android.com/aapt">
@ -14,8 +14,8 @@
android:endY="108"
android:endX="108"
android:type="linear">
<item android:offset="0" android:color="#FF1A1A2E"/>
<item android:offset="1" android:color="#FF16213E"/>
<item android:offset="0" android:color="#FFA8E6CF"/>
<item android:offset="1" android:color="#FF88C9E8"/>
</gradient>
</aapt:attr>
</path>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground>
<inset android:drawable="@drawable/taozhi_icon_v2" android:inset="10%" />
</foreground>
</adaptive-icon>

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground>
<inset android:drawable="@drawable/taozhi_icon_v2" android:inset="10%" />
</foreground>
</adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 96 KiB