diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 70da39c..c3888ba 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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") diff --git a/app/src/main/java/com/example/smarthome/MainActivity.kt b/app/src/main/java/com/example/smarthome/MainActivity.kt index 50780ad..d2b6d01 100644 --- a/app/src/main/java/com/example/smarthome/MainActivity.kt +++ b/app/src/main/java/com/example/smarthome/MainActivity.kt @@ -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) } diff --git a/app/src/main/java/com/example/smarthome/data/LanguageManager.kt b/app/src/main/java/com/example/smarthome/data/LanguageManager.kt index e0cae88..c917ee4 100644 --- a/app/src/main/java/com/example/smarthome/data/LanguageManager.kt +++ b/app/src/main/java/com/example/smarthome/data/LanguageManager.kt @@ -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", diff --git a/app/src/main/java/com/example/smarthome/data/UserManager.kt b/app/src/main/java/com/example/smarthome/data/UserManager.kt new file mode 100644 index 0000000..8b1b9d1 --- /dev/null +++ b/app/src/main/java/com/example/smarthome/data/UserManager.kt @@ -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 = _isLoggedIn + + private val _userInfo = MutableStateFlow(UserInfo()) + val userInfo: StateFlow = _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 { + 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) + } +} diff --git a/app/src/main/java/com/example/smarthome/ui/LoginScreen.kt b/app/src/main/java/com/example/smarthome/ui/LoginScreen.kt new file mode 100644 index 0000000..cdb1a7d --- /dev/null +++ b/app/src/main/java/com/example/smarthome/ui/LoginScreen.kt @@ -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(null) } + var sessionId by remember { mutableStateOf("") } + var errorMessage by remember { mutableStateOf(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 = 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 // 已确认登录 +} diff --git a/app/src/main/java/com/example/smarthome/ui/MainScaffold.kt b/app/src/main/java/com/example/smarthome/ui/MainScaffold.kt index 082927c..8a33c0d 100644 --- a/app/src/main/java/com/example/smarthome/ui/MainScaffold.kt +++ b/app/src/main/java/com/example/smarthome/ui/MainScaffold.kt @@ -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) - ) - } - } - } } } } diff --git a/app/src/main/java/com/example/smarthome/ui/PhoneLoginScreen.kt b/app/src/main/java/com/example/smarthome/ui/PhoneLoginScreen.kt new file mode 100644 index 0000000..46e1d1e --- /dev/null +++ b/app/src/main/java/com/example/smarthome/ui/PhoneLoginScreen.kt @@ -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(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 + ) + } + } + } +} diff --git a/app/src/main/java/com/example/smarthome/ui/SettingsScreen.kt b/app/src/main/java/com/example/smarthome/ui/SettingsScreen.kt index 3146bd3..66f0781 100644 --- a/app/src/main/java/com/example/smarthome/ui/SettingsScreen.kt +++ b/app/src/main/java/com/example/smarthome/ui/SettingsScreen.kt @@ -146,7 +146,9 @@ fun SettingsContentList() { item { Spacer(modifier = Modifier.height(16.dp)) - LogoutButton(onClick = { }) + LogoutButton(onClick = { + com.example.smarthome.data.UserManager.logout(context) + }) } } } diff --git a/app/src/main/res/drawable/fan_high.png b/app/src/main/res/drawable/fan_high.png new file mode 100644 index 0000000..ad7bceb Binary files /dev/null and b/app/src/main/res/drawable/fan_high.png differ diff --git a/app/src/main/res/drawable/fan_low.png b/app/src/main/res/drawable/fan_low.png new file mode 100644 index 0000000..6df68ea Binary files /dev/null and b/app/src/main/res/drawable/fan_low.png differ diff --git a/app/src/main/res/drawable/fan_medium.png b/app/src/main/res/drawable/fan_medium.png new file mode 100644 index 0000000..235ddc7 Binary files /dev/null and b/app/src/main/res/drawable/fan_medium.png differ diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml index e43158f..4ff7b39 100644 --- a/app/src/main/res/drawable/ic_launcher_background.xml +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -4,7 +4,7 @@ android:viewportWidth="108" android:viewportHeight="108"> - + @@ -14,8 +14,8 @@ android:endY="108" android:endX="108" android:type="linear"> - - + + diff --git a/app/src/main/res/drawable/taozhi_icon.png b/app/src/main/res/drawable/taozhi_icon.png new file mode 100644 index 0000000..1dac019 Binary files /dev/null and b/app/src/main/res/drawable/taozhi_icon.png differ diff --git a/app/src/main/res/drawable/taozhi_icon_new.png b/app/src/main/res/drawable/taozhi_icon_new.png new file mode 100644 index 0000000..19b2d4f Binary files /dev/null and b/app/src/main/res/drawable/taozhi_icon_new.png differ diff --git a/app/src/main/res/drawable/taozhi_icon_v2.png b/app/src/main/res/drawable/taozhi_icon_v2.png new file mode 100644 index 0000000..b9b8978 Binary files /dev/null and b/app/src/main/res/drawable/taozhi_icon_v2.png differ diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..d3dee3d --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..d3dee3d --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png index e2d9ed7..7a83a02 100644 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png index e2d9ed7..7a83a02 100644 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png index 4cfa370..818b1c3 100644 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png index 4cfa370..818b1c3 100644 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png index b0c2dfb..fad471c 100644 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png index b0c2dfb..fad471c 100644 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index b2911e1..aa2ae45 100644 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png index b2911e1..aa2ae45 100644 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index 3f55fb0..ac85b89 100644 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png index 3f55fb0..ac85b89 100644 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ