Compare commits

...

17 Commits

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectRootManager">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/email.iml" filepath="$PROJECT_DIR$/.idea/email.iml" />
</modules>
</component>
</project>

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

@ -0,0 +1,47 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AutoImportSettings">
<option name="autoReloadType" value="NONE" />
</component>
<component name="ChangeListManager">
<list default="true" id="945587a8-7a4d-45ef-a458-1d4c53c38ecd" name="Changes" comment="" />
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
<option name="LAST_RESOLUTION" value="IGNORE" />
</component>
<component name="ClangdSettings">
<option name="formatViaClangd" value="false" />
</component>
<component name="Git.Settings">
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
</component>
<component name="ProjectColorInfo"><![CDATA[{
"associatedIndex": 4
}]]></component>
<component name="ProjectId" id="3713RUBgHpQQc6mqLb73Rojp9Dc" />
<component name="ProjectViewState">
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent"><![CDATA[{
"keyToString": {
"ModuleVcsDetector.initialDetectionPerformed": "true",
"RunOnceActivity.ShowReadmeOnStart": "true",
"RunOnceActivity.cidr.known.project.marker": "true",
"RunOnceActivity.readMode.enableVisualFormatting": "true",
"cf.first.check.clang-format": "false",
"cidr.known.project.marker": "true"
}
}]]></component>
<component name="TaskManager">
<task active="true" id="Default" summary="Default task">
<changelist id="945587a8-7a4d-45ef-a458-1d4c53c38ecd" name="Changes" comment="" />
<created>1766056742981</created>
<option name="number" value="Default" />
<option name="presentableId" value="Default" />
<updated>1766056742981</updated>
</task>
<servers />
</component>
</project>

@ -0,0 +1,2 @@
#Sat Dec 13 11:54:16 CST 2025
gradle.version=8.5

@ -0,0 +1,2 @@
#Sat Dec 13 11:24:51 CST 2025
java.home=E\:\\Android Studio\\jbr

@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AndroidProjectSystem">
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
</component>
</project>

File diff suppressed because it is too large Load Diff

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="testRunner" value="CHOOSE_PER_TEST" />
<option name="distributionType" value="LOCAL" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleHome" value="E:\Gradle\gradle-8.5" />
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
</GradleProjectSettings>
</option>
</component>
</project>

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="MarkdownSettings">
<option name="previewPanelProviderInfo">
<ProviderInfo name="Compose (experimental)" className="com.intellij.markdown.compose.preview.ComposePanelProvider" />
</option>
</component>
</project>

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectMigrations">
<option name="MigrateToGradleLocalJavaHome">
<set>
<option value="$PROJECT_DIR$" />
</set>
</option>
</component>
</project>

@ -0,0 +1,9 @@
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RunConfigurationProducerService">
<option name="ignoredProducers">
<set>
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
</set>
</option>
</component>
</project>

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
</component>
</project>

@ -0,0 +1,116 @@
# 邮件客户端 Android App
基于SMTP/POP3协议的Android邮件客户端配合Flask邮件服务器使用。
## 功能特性
- 用户注册/登录通过HTTP API
- **通过POP3协议接收邮件**
- **通过SMTP协议发送邮件**
- **通过POP3协议删除邮件**
- 通讯录管理通过HTTP API
- 服务器配置
## 协议使用说明
### 邮件功能SMTP/POP3
| 功能 | 协议 | 说明 |
|------|------|------|
| 发送邮件 | SMTP | 连接SMTP服务器(端口2525)发送 |
| 接收邮件 | POP3 | 连接POP3服务器(端口1100)获取 |
| 删除邮件 | POP3 | 通过POP3的DELE命令删除 |
### 其他功能HTTP API
| 功能 | 接口 |
|------|------|
| 用户登录 | POST /api/auth/login |
| 用户注册 | POST /api/auth/register |
| 通讯录管理 | /api/contacts |
## 项目结构
```
EmailClientAndroid/
├── app/src/main/java/com/example/emailclient/
│ ├── data/ # 数据模型
│ ├── network/
│ │ ├── SmtpClient.kt # SMTP客户端(发送邮件)
│ │ ├── Pop3Client.kt # POP3客户端(接收/删除邮件)
│ │ ├── ApiService.kt # HTTP API(认证/通讯录)
│ │ └── RetrofitClient.kt
│ └── ui/ # 界面层
└── app/src/main/res/ # 资源文件
```
## SMTP发送邮件流程
```
1. 连接SMTP服务器 (端口2525)
2. 接收: 220 服务器就绪
3. 发送: EHLO localhost
4. 接收: 250 OK
5. 发送: MAIL FROM:<发件人邮箱>
6. 接收: 250 OK
7. 发送: RCPT TO:<收件人邮箱>
8. 接收: 250 OK
9. 发送: DATA
10. 接收: 354 开始输入
11. 发送: 邮件头和正文,以"."结束
12. 接收: 250 OK
13. 发送: QUIT
```
## POP3接收邮件流程
```
1. 连接POP3服务器 (端口1100)
2. 接收: +OK 服务器就绪
3. 发送: USER 用户名
4. 接收: +OK
5. 发送: PASS 密码
6. 接收: +OK 登录成功
7. 发送: STAT
8. 接收: +OK 邮件数量 总大小
9. 发送: UIDL (获取邮件唯一ID)
10. 发送: RETR n (获取第n封邮件)
11. 发送: DELE n (删除第n封邮件)
12. 发送: QUIT (确认删除并断开)
```
## 配置说明
在App设置页面配置
- 服务器地址邮件服务器IP
- 模拟器使用 `10.0.2.2` 访问本机
- 真机使用服务器实际IP
- SMTP端口默认 `2525`
- POP3端口默认 `1100`
- API端口默认 `5000`
## 编译运行
1. 启动服务端:
```bash
cd email_system
pip install -r requirements.txt
python app.py
```
2. 用Android Studio打开`EmailClientAndroid`目录
3. 同步Gradle依赖
4. 在设置中配置服务器地址
5. 运行应用
## 使用流程
1. 注册/登录账号
2. 输入密码验证身份用于POP3认证
3. 自动通过POP3获取收件箱邮件
4. 点击邮件查看详情
5. 长按邮件可删除通过POP3
6. 点击右下角按钮写邮件通过SMTP发送

@ -0,0 +1,65 @@
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
id 'kotlin-kapt'
}
android {
namespace 'com.example.emailclient'
compileSdk 34
defaultConfig {
applicationId "com.example.emailclient"
minSdk 24
targetSdk 34
versionCode 1
versionName "1.0"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '17'
}
buildFeatures {
viewBinding true
}
}
dependencies {
implementation 'androidx.core:core-ktx:1.12.0'
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'com.google.android.material:material:1.11.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.recyclerview:recyclerview:1.3.2'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
// Retrofit for API calls
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
implementation 'com.squareup.okhttp3:logging-interceptor:4.12.0'
// Coroutines
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.7.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0'
// DataStore for preferences
implementation 'androidx.datastore:datastore-preferences:1.0.0'
// Room for local database
implementation 'androidx.room:room-runtime:2.6.1'
implementation 'androidx.room:room-ktx:2.6.1'
kapt 'androidx.room:room-compiler:2.6.1'
}

@ -0,0 +1,17 @@
# Retrofit
-keepattributes Signature
-keepattributes *Annotation*
-keep class retrofit2.** { *; }
-keepclasseswithmembers class * {
@retrofit2.http.* <methods>;
}
# Gson
-keep class com.example.emailclient.data.** { *; }
-keepclassmembers class * {
@com.google.gson.annotations.SerializedName <fields>;
}
# OkHttp
-dontwarn okhttp3.**
-dontwarn okio.**

@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:name=".EmailApp"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.EmailClient"
android:usesCleartextTraffic="true">
<activity
android:name=".ui.SplashActivity"
android:exported="true"
android:theme="@style/Theme.EmailClient.Splash">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity android:name=".ui.LoginActivity" android:exported="false" />
<activity android:name=".ui.RegisterActivity" android:exported="false" />
<activity android:name=".ui.MainActivity" android:exported="false" />
<activity android:name=".ui.ComposeActivity" android:exported="false" />
<activity android:name=".ui.EmailDetailActivity" android:exported="false" />
<activity android:name=".ui.SettingsActivity" android:exported="false" />
<activity android:name=".ui.ContactsActivity" android:exported="false" />
<activity android:name=".ui.ContactDetailActivity" android:exported="false" />
<activity android:name=".ui.AdminActivity" android:exported="false" />
<activity android:name=".ui.StarredActivity" android:exported="false" />
<activity android:name=".ui.StarredEmailDetailActivity" android:exported="false" />
<activity android:name=".ui.LocalEmailDetailActivity" android:exported="false" />
</application>
</manifest>

@ -0,0 +1,25 @@
package com.example.emailclient
import android.app.Application
import com.example.emailclient.data.PreferencesManager
import com.example.emailclient.data.local.EmailRepository
class EmailApp : Application() {
lateinit var preferencesManager: PreferencesManager
private set
lateinit var emailRepository: EmailRepository
private set
override fun onCreate() {
super.onCreate()
instance = this
preferencesManager = PreferencesManager(this)
emailRepository = EmailRepository(this)
}
companion object {
lateinit var instance: EmailApp
private set
}
}

@ -0,0 +1,181 @@
package com.example.emailclient.data
import com.google.gson.annotations.SerializedName
// API响应
data class ApiResponse<T>(
val code: Int,
val message: String,
val data: T?
)
// 用户
data class User(
val id: Int,
val username: String,
val email: String,
@SerializedName("is_admin") val isAdmin: Boolean = false
)
data class LoginResponse(
val token: String,
val user: User
)
// 联系人
data class Contact(
val id: Int,
val name: String,
val email: String,
val note: String,
@SerializedName("created_at") val createdAt: String
)
data class ContactListResponse(
val contacts: List<Contact>,
val total: Int
)
// 联系人邮件
data class ContactEmail(
val id: Int,
@SerializedName("sender_address") val senderAddress: String,
@SerializedName("recipient_address") val recipientAddress: String,
val subject: String,
val body: String,
@SerializedName("created_at") val createdAt: String,
@SerializedName("is_read") val isRead: Boolean,
@SerializedName("is_sent") val isSent: Boolean
)
data class ContactEmailsResponse(
val contact: Contact,
val emails: List<ContactEmail>,
val total: Int
)
// 请求体
data class LoginRequest(val username: String, val password: String)
data class RegisterRequest(val username: String, val email: String, val password: String)
data class AddContactRequest(val name: String, val email: String, val note: String = "")
data class UpdateContactRequest(val name: String, val note: String = "")
data class AutoAddContactRequest(val email: String, val name: String? = null)
data class ChangePasswordRequest(
@SerializedName("old_password") val oldPassword: String,
@SerializedName("new_password") val newPassword: String
)
// ==================== 管理员相关 ====================
data class AdminDashboard(
@SerializedName("user_count") val userCount: Int,
@SerializedName("email_count") val emailCount: Int,
@SerializedName("smtp_port") val smtpPort: Int,
@SerializedName("pop3_port") val pop3Port: Int,
val domain: String
)
data class AdminUser(
val id: Int,
val username: String,
val email: String,
@SerializedName("is_admin") val isAdmin: Boolean,
@SerializedName("is_active") val isActive: Boolean,
@SerializedName("created_at") val createdAt: String
)
data class AdminUserListResponse(
val users: List<AdminUser>,
val total: Int
)
data class AddUserRequest(
val username: String,
val email: String,
val password: String,
@SerializedName("is_admin") val isAdmin: Boolean = false
)
data class ToggleUserResponse(
@SerializedName("is_active") val isActive: Boolean
)
data class EmailFilter(
val id: Int,
@SerializedName("filter_type") val filterType: String,
val value: String,
val action: String,
@SerializedName("created_at") val createdAt: String
)
data class FilterListResponse(
val filters: List<EmailFilter>,
val total: Int
)
data class AddFilterRequest(
@SerializedName("filter_type") val filterType: String,
val value: String,
val action: String = "block"
)
data class LogEntry(
val id: Int,
val level: String,
val message: String,
val source: String?,
@SerializedName("ip_address") val ipAddress: String?,
@SerializedName("created_at") val createdAt: String
)
data class LogListResponse(
val logs: List<LogEntry>,
val total: Int,
val page: Int,
val pages: Int,
@SerializedName("has_next") val hasNext: Boolean,
@SerializedName("has_prev") val hasPrev: Boolean
)
data class BroadcastRequest(
val subject: String,
val body: String
)
data class BroadcastResponse(
@SerializedName("sent_count") val sentCount: Int
)
// ==================== 星标邮件相关 ====================
data class StarredEmail(
val id: Int,
@SerializedName("sender_address") val senderAddress: String,
@SerializedName("recipient_address") val recipientAddress: String,
val subject: String,
val body: String,
@SerializedName("created_at") val createdAt: String,
@SerializedName("is_read") val isRead: Boolean,
@SerializedName("is_starred") val isStarred: Boolean
)
data class StarredEmailListResponse(
val emails: List<StarredEmail>,
val total: Int
)
data class ToggleStarResponse(
val id: Int,
@SerializedName("is_starred") val isStarred: Boolean
)
data class StarByInfoRequest(
val sender: String,
val subject: String
)
data class CheckStarredResponse(
val id: Int? = null,
@SerializedName("is_starred") val isStarred: Boolean,
val found: Boolean
)

@ -0,0 +1,81 @@
package com.example.emailclient.data
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.runBlocking
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
class PreferencesManager(private val context: Context) {
companion object {
val TOKEN = stringPreferencesKey("token")
val USERNAME = stringPreferencesKey("username")
val EMAIL = stringPreferencesKey("email")
val IS_ADMIN = stringPreferencesKey("is_admin")
val SERVER_HOST = stringPreferencesKey("server_host")
val SMTP_PORT = stringPreferencesKey("smtp_port")
val POP3_PORT = stringPreferencesKey("pop3_port")
val API_PORT = stringPreferencesKey("api_port")
}
val token: Flow<String?> = context.dataStore.data.map { it[TOKEN] }
val username: Flow<String?> = context.dataStore.data.map { it[USERNAME] }
val email: Flow<String?> = context.dataStore.data.map { it[EMAIL] }
val isAdmin: Flow<Boolean> = context.dataStore.data.map { it[IS_ADMIN] == "true" }
fun getTokenSync(): String? = runBlocking { token.first() }
fun getUsernameSync(): String? = runBlocking { username.first() }
fun getEmailSync(): String? = runBlocking { email.first() }
fun isAdminSync(): Boolean = runBlocking { isAdmin.first() }
fun getServerHost(): String = runBlocking {
context.dataStore.data.first()[SERVER_HOST] ?: "113.45.148.222"
}
fun getSmtpPort(): Int = runBlocking {
context.dataStore.data.first()[SMTP_PORT]?.toIntOrNull() ?: 25
}
fun getPop3Port(): Int = runBlocking {
context.dataStore.data.first()[POP3_PORT]?.toIntOrNull() ?: 110
}
fun getApiPort(): Int = runBlocking {
context.dataStore.data.first()[API_PORT]?.toIntOrNull() ?: 5000
}
suspend fun saveUserInfo(token: String, username: String, email: String, isAdmin: Boolean = false) {
context.dataStore.edit {
it[TOKEN] = token
it[USERNAME] = username
it[EMAIL] = email
it[IS_ADMIN] = isAdmin.toString()
}
}
suspend fun saveServerConfig(host: String, smtpPort: Int, pop3Port: Int, apiPort: Int) {
context.dataStore.edit {
it[SERVER_HOST] = host
it[SMTP_PORT] = smtpPort.toString()
it[POP3_PORT] = pop3Port.toString()
it[API_PORT] = apiPort.toString()
}
}
suspend fun clearUserInfo() {
context.dataStore.edit {
it.remove(TOKEN)
it.remove(USERNAME)
it.remove(EMAIL)
it.remove(IS_ADMIN)
}
}
}

@ -0,0 +1,29 @@
package com.example.emailclient.data.local
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
@Database(entities = [LocalEmail::class], version = 1, exportSchema = false)
abstract class EmailDatabase : RoomDatabase() {
abstract fun emailDao(): EmailDao
companion object {
@Volatile
private var INSTANCE: EmailDatabase? = null
fun getInstance(context: Context): EmailDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
EmailDatabase::class.java,
"email_database"
).build()
INSTANCE = instance
instance
}
}
}
}

@ -0,0 +1,91 @@
package com.example.emailclient.data.local
import android.content.Context
/**
* 邮件仓库 - 统一管理本地邮件数据
*/
class EmailRepository(context: Context) {
private val emailDao = EmailDatabase.getInstance(context).emailDao()
// 收件箱
suspend fun getInboxEmails() = emailDao.getInboxEmails()
// 发件箱
suspend fun getSentEmails() = emailDao.getSentEmails()
// 草稿箱
suspend fun getDraftEmails() = emailDao.getDraftEmails()
// 星标邮件
suspend fun getStarredEmails() = emailDao.getStarredEmails()
// 获取单封邮件
suspend fun getEmailById(id: Long) = emailDao.getEmailById(id)
// 保存收到的邮件
suspend fun saveInboxEmail(from: String, to: String, subject: String, body: String): Long {
val email = LocalEmail(
senderAddress = from,
recipientAddress = to,
subject = subject,
body = body,
folderType = LocalEmail.FOLDER_INBOX,
isSent = false
)
return emailDao.insert(email)
}
// 保存已发送邮件
suspend fun saveSentEmail(from: String, to: String, subject: String, body: String): Long {
val email = LocalEmail(
senderAddress = from,
recipientAddress = to,
subject = subject,
body = body,
folderType = LocalEmail.FOLDER_SENT,
isSent = true,
isRead = true
)
return emailDao.insert(email)
}
// 保存草稿
suspend fun saveDraft(from: String, to: String, subject: String, body: String): Long {
val email = LocalEmail(
senderAddress = from,
recipientAddress = to,
subject = subject,
body = body,
folderType = LocalEmail.FOLDER_DRAFT,
isDraft = true
)
return emailDao.insert(email)
}
// 更新草稿
suspend fun updateDraft(id: Long, to: String, subject: String, body: String) {
val email = emailDao.getEmailById(id) ?: return
emailDao.update(email.copy(
recipientAddress = to,
subject = subject,
body = body
))
}
// 删除邮件
suspend fun deleteEmail(id: Long) = emailDao.deleteById(id)
// 切换星标
suspend fun toggleStar(id: Long) = emailDao.toggleStar(id)
// 标记已读
suspend fun markAsRead(id: Long) = emailDao.markAsRead(id)
// 发送草稿(移动到发件箱)
suspend fun sendDraft(id: Long) = emailDao.moveDraftToSent(id)
// 搜索
suspend fun searchEmails(query: String) = emailDao.searchEmails(query)
}

@ -0,0 +1,94 @@
package com.example.emailclient.data.local
import androidx.room.*
/**
* 本地邮件实体
*/
@Entity(tableName = "emails")
data class LocalEmail(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
@ColumnInfo(name = "sender_address")
val senderAddress: String,
@ColumnInfo(name = "recipient_address")
val recipientAddress: String,
val subject: String,
val body: String,
@ColumnInfo(name = "created_at")
val createdAt: Long = System.currentTimeMillis(),
@ColumnInfo(name = "is_read")
val isRead: Boolean = false,
@ColumnInfo(name = "is_starred")
val isStarred: Boolean = false,
@ColumnInfo(name = "is_draft")
val isDraft: Boolean = false,
@ColumnInfo(name = "is_sent")
val isSent: Boolean = false, // true=发件箱, false=收件箱
@ColumnInfo(name = "folder_type")
val folderType: Int = FOLDER_INBOX // 0=收件箱, 1=发件箱, 2=草稿箱
) {
companion object {
const val FOLDER_INBOX = 0
const val FOLDER_SENT = 1
const val FOLDER_DRAFT = 2
}
}
/**
* 邮件DAO
*/
@Dao
interface EmailDao {
// 收件箱
@Query("SELECT * FROM emails WHERE folder_type = 0 ORDER BY created_at DESC")
suspend fun getInboxEmails(): List<LocalEmail>
// 发件箱
@Query("SELECT * FROM emails WHERE folder_type = 1 ORDER BY created_at DESC")
suspend fun getSentEmails(): List<LocalEmail>
// 草稿箱
@Query("SELECT * FROM emails WHERE folder_type = 2 ORDER BY created_at DESC")
suspend fun getDraftEmails(): List<LocalEmail>
// 星标邮件
@Query("SELECT * FROM emails WHERE is_starred = 1 ORDER BY created_at DESC")
suspend fun getStarredEmails(): List<LocalEmail>
// 根据ID获取邮件
@Query("SELECT * FROM emails WHERE id = :id")
suspend fun getEmailById(id: Long): LocalEmail?
// 插入邮件
@Insert
suspend fun insert(email: LocalEmail): Long
// 更新邮件
@Update
suspend fun update(email: LocalEmail)
// 删除邮件
@Delete
suspend fun delete(email: LocalEmail)
// 根据ID删除
@Query("DELETE FROM emails WHERE id = :id")
suspend fun deleteById(id: Long)
// 切换星标状态
@Query("UPDATE emails SET is_starred = NOT is_starred WHERE id = :id")
suspend fun toggleStar(id: Long)
// 标记为已读
@Query("UPDATE emails SET is_read = 1 WHERE id = :id")
suspend fun markAsRead(id: Long)
// 将草稿移动到发件箱
@Query("UPDATE emails SET folder_type = 1, is_draft = 0, is_sent = 1 WHERE id = :id")
suspend fun moveDraftToSent(id: Long)
// 搜索邮件
@Query("SELECT * FROM emails WHERE subject LIKE '%' || :query || '%' OR body LIKE '%' || :query || '%' ORDER BY created_at DESC")
suspend fun searchEmails(query: String): List<LocalEmail>
}

@ -0,0 +1,164 @@
package com.example.emailclient.network
import com.example.emailclient.data.*
import retrofit2.Response
import retrofit2.http.*
/**
* API接口 - 仅用于认证和通讯录
* 邮件收发通过SMTP/POP3协议实现
*/
interface ApiService {
// ==================== 认证 ====================
@POST("api/auth/login")
suspend fun login(@Body request: LoginRequest): Response<ApiResponse<LoginResponse>>
@POST("api/auth/register")
suspend fun register(@Body request: RegisterRequest): Response<ApiResponse<Any>>
@POST("api/auth/logout")
suspend fun logout(@Header("Authorization") token: String): Response<ApiResponse<Any>>
@GET("api/auth/profile")
suspend fun getProfile(@Header("Authorization") token: String): Response<ApiResponse<User>>
@PUT("api/auth/password")
suspend fun changePassword(
@Header("Authorization") token: String,
@Body request: ChangePasswordRequest
): Response<ApiResponse<Any>>
// ==================== 通讯录 ====================
@GET("api/contacts")
suspend fun getContacts(@Header("Authorization") token: String): Response<ApiResponse<ContactListResponse>>
@POST("api/contacts")
suspend fun addContact(
@Header("Authorization") token: String,
@Body request: AddContactRequest
): Response<ApiResponse<Contact>>
@DELETE("api/contacts/{id}")
suspend fun deleteContact(
@Header("Authorization") token: String,
@Path("id") id: Int
): Response<ApiResponse<Any>>
@PUT("api/contacts/{id}")
suspend fun updateContact(
@Header("Authorization") token: String,
@Path("id") id: Int,
@Body request: UpdateContactRequest
): Response<ApiResponse<Contact>>
@GET("api/contacts/{id}/emails")
suspend fun getContactEmails(
@Header("Authorization") token: String,
@Path("id") id: Int
): Response<ApiResponse<ContactEmailsResponse>>
@POST("api/contacts/auto-add")
suspend fun autoAddContact(
@Header("Authorization") token: String,
@Body request: AutoAddContactRequest
): Response<ApiResponse<Contact>>
@GET("api/contacts/search")
suspend fun searchContacts(
@Header("Authorization") token: String,
@Query("q") query: String
): Response<ApiResponse<ContactListResponse>>
// ==================== 管理员接口 ====================
@GET("api/admin/dashboard")
suspend fun getAdminDashboard(
@Header("Authorization") token: String
): Response<ApiResponse<AdminDashboard>>
@GET("api/admin/users")
suspend fun getAdminUsers(
@Header("Authorization") token: String
): Response<ApiResponse<AdminUserListResponse>>
@POST("api/admin/users")
suspend fun addAdminUser(
@Header("Authorization") token: String,
@Body request: AddUserRequest
): Response<ApiResponse<Any>>
@POST("api/admin/users/{id}/toggle")
suspend fun toggleUser(
@Header("Authorization") token: String,
@Path("id") id: Int
): Response<ApiResponse<ToggleUserResponse>>
@DELETE("api/admin/users/{id}")
suspend fun deleteUser(
@Header("Authorization") token: String,
@Path("id") id: Int
): Response<ApiResponse<Any>>
@GET("api/admin/filters")
suspend fun getFilters(
@Header("Authorization") token: String
): Response<ApiResponse<FilterListResponse>>
@POST("api/admin/filters")
suspend fun addFilter(
@Header("Authorization") token: String,
@Body request: AddFilterRequest
): Response<ApiResponse<Any>>
@DELETE("api/admin/filters/{id}")
suspend fun deleteFilter(
@Header("Authorization") token: String,
@Path("id") id: Int
): Response<ApiResponse<Any>>
@GET("api/admin/logs")
suspend fun getLogs(
@Header("Authorization") token: String,
@Query("page") page: Int = 1,
@Query("per_page") perPage: Int = 50
): Response<ApiResponse<LogListResponse>>
@POST("api/admin/logs/clear")
suspend fun clearLogs(
@Header("Authorization") token: String
): Response<ApiResponse<Any>>
@POST("api/admin/broadcast")
suspend fun broadcast(
@Header("Authorization") token: String,
@Body request: BroadcastRequest
): Response<ApiResponse<BroadcastResponse>>
// ==================== 星标邮件接口 ====================
@GET("api/emails/starred")
suspend fun getStarredEmails(
@Header("Authorization") token: String
): Response<ApiResponse<StarredEmailListResponse>>
@POST("api/emails/{id}/star")
suspend fun toggleStar(
@Header("Authorization") token: String,
@Path("id") id: Int
): Response<ApiResponse<ToggleStarResponse>>
@POST("api/emails/star-by-info")
suspend fun toggleStarByInfo(
@Header("Authorization") token: String,
@Body request: StarByInfoRequest
): Response<ApiResponse<ToggleStarResponse>>
@POST("api/emails/check-starred")
suspend fun checkStarred(
@Header("Authorization") token: String,
@Body request: StarByInfoRequest
): Response<ApiResponse<CheckStarredResponse>>
}

@ -0,0 +1,211 @@
package com.example.emailclient.network
import com.example.emailclient.EmailApp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.BufferedReader
import java.io.InputStreamReader
import java.io.PrintWriter
import java.net.Socket
import java.text.SimpleDateFormat
import java.util.*
data class Pop3Email(
val id: Int,
val uid: String,
val from: String,
val to: String,
val subject: String,
val body: String,
val rawData: String
)
class Pop3Client {
private val prefs = EmailApp.instance.preferencesManager
suspend fun fetchEmails(username: String, password: String): Result<List<Pop3Email>> =
withContext(Dispatchers.IO) {
var socket: Socket? = null
try {
val host = prefs.getServerHost()
val port = prefs.getPop3Port()
socket = Socket(host, port)
val reader = BufferedReader(InputStreamReader(socket.getInputStream()))
val writer = PrintWriter(socket.getOutputStream(), true)
// 读取欢迎消息
var response = reader.readLine()
if (!response.startsWith("+OK")) {
return@withContext Result.failure(Exception("连接失败: $response"))
}
// USER
writer.println("USER $username")
response = reader.readLine()
if (!response.startsWith("+OK")) {
return@withContext Result.failure(Exception("用户名错误: $response"))
}
// PASS
writer.println("PASS $password")
response = reader.readLine()
if (!response.startsWith("+OK")) {
return@withContext Result.failure(Exception("密码错误: $response"))
}
// STAT获取邮件数量
writer.println("STAT")
response = reader.readLine()
if (!response.startsWith("+OK")) {
return@withContext Result.failure(Exception("STAT失败: $response"))
}
val parts = response.split(" ")
val count = if (parts.size >= 2) parts[1].toIntOrNull() ?: 0 else 0
val emails = mutableListOf<Pop3Email>()
// 获取每封邮件的UID
writer.println("UIDL")
response = reader.readLine()
if (!response.startsWith("+OK")) {
return@withContext Result.failure(Exception("UIDL失败: $response"))
}
val uidMap = mutableMapOf<Int, String>()
while (true) {
val line = reader.readLine()
if (line == ".") break
val uidParts = line.split(" ")
if (uidParts.size >= 2) {
uidMap[uidParts[0].toIntOrNull() ?: 0] = uidParts[1]
}
}
// 获取每封邮件内容
for (i in 1..count) {
writer.println("RETR $i")
response = reader.readLine()
if (!response.startsWith("+OK")) continue
val emailContent = StringBuilder()
while (true) {
val line = reader.readLine()
if (line == ".") break
emailContent.appendLine(line)
}
val rawData = emailContent.toString()
val email = parseEmail(i, uidMap[i] ?: "", rawData)
emails.add(email)
}
// QUIT
writer.println("QUIT")
reader.readLine()
Result.success(emails.reversed()) // 最新的在前
} catch (e: Exception) {
Result.failure(e)
} finally {
try {
socket?.close()
} catch (_: Exception) {}
}
}
suspend fun deleteEmail(username: String, password: String, messageNumber: Int): Result<Unit> =
withContext(Dispatchers.IO) {
var socket: Socket? = null
try {
val host = prefs.getServerHost()
val port = prefs.getPop3Port()
socket = Socket(host, port)
val reader = BufferedReader(InputStreamReader(socket.getInputStream()))
val writer = PrintWriter(socket.getOutputStream(), true)
reader.readLine() // 欢迎消息
writer.println("USER $username")
reader.readLine()
writer.println("PASS $password")
val response = reader.readLine()
if (!response.startsWith("+OK")) {
return@withContext Result.failure(Exception("认证失败"))
}
writer.println("DELE $messageNumber")
val deleResponse = reader.readLine()
writer.println("QUIT")
reader.readLine()
if (deleResponse.startsWith("+OK")) {
Result.success(Unit)
} else {
Result.failure(Exception("删除失败: $deleResponse"))
}
} catch (e: Exception) {
Result.failure(e)
} finally {
try {
socket?.close()
} catch (_: Exception) {}
}
}
private fun parseEmail(id: Int, uid: String, rawData: String): Pop3Email {
val lines = rawData.lines()
var from = ""
var to = ""
var subject = ""
var headerEnd = 0
for ((index, line) in lines.withIndex()) {
when {
line.isEmpty() -> {
headerEnd = index
break
}
line.lowercase().startsWith("from:") -> {
from = line.substringAfter(":").trim()
// 提取邮箱地址
val match = Regex("<(.+?)>").find(from)
if (match != null) {
from = match.groupValues[1]
}
}
line.lowercase().startsWith("to:") -> {
to = line.substringAfter(":").trim()
val match = Regex("<(.+?)>").find(to)
if (match != null) {
to = match.groupValues[1]
}
}
line.lowercase().startsWith("subject:") -> {
subject = line.substringAfter(":").trim()
}
}
}
val body = if (headerEnd > 0 && headerEnd < lines.size - 1) {
lines.subList(headerEnd + 1, lines.size).joinToString("\n")
} else {
rawData
}
return Pop3Email(
id = id,
uid = uid,
from = from,
to = to,
subject = subject.ifEmpty { "(无主题)" },
body = body.trim(),
rawData = rawData
)
}
}

@ -0,0 +1,61 @@
package com.example.emailclient.network
import com.example.emailclient.EmailApp
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit
object RetrofitClient {
private var retrofit: Retrofit? = null
private var currentBaseUrl: String? = null
private fun getBaseUrl(): String {
val prefs = EmailApp.instance.preferencesManager
val host = prefs.getServerHost()
val port = prefs.getApiPort()
return "http://$host:$port/"
}
fun getApiService(): ApiService {
val baseUrl = getBaseUrl()
if (retrofit == null || currentBaseUrl != baseUrl) {
val logging = HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
}
val client = OkHttpClient.Builder()
.addInterceptor(logging)
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.retryOnConnectionFailure(true)
.connectionPool(okhttp3.ConnectionPool(0, 1, TimeUnit.MILLISECONDS))
.addInterceptor { chain ->
val request = chain.request().newBuilder()
.header("Connection", "close")
.build()
chain.proceed(request)
}
.build()
retrofit = Retrofit.Builder()
.baseUrl(baseUrl)
.client(client)
.addConverterFactory(GsonConverterFactory.create())
.build()
currentBaseUrl = baseUrl
}
return retrofit!!.create(ApiService::class.java)
}
fun resetClient() {
retrofit = null
currentBaseUrl = null
}
}

@ -0,0 +1,104 @@
package com.example.emailclient.network
import com.example.emailclient.EmailApp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.BufferedReader
import java.io.InputStreamReader
import java.io.OutputStreamWriter
import java.io.PrintWriter
import java.net.Socket
class SmtpClient {
private val prefs = EmailApp.instance.preferencesManager
suspend fun sendEmail(
from: String,
to: String,
subject: String,
body: String
): Result<Unit> = withContext(Dispatchers.IO) {
var socket: Socket? = null
try {
val host = prefs.getServerHost()
val port = prefs.getSmtpPort()
socket = Socket(host, port)
socket.soTimeout = 30000
val reader = BufferedReader(InputStreamReader(socket.getInputStream(), "UTF-8"))
val output = socket.getOutputStream()
// 辅助函数发送命令使用CRLF
fun sendCommand(cmd: String) {
output.write("$cmd\r\n".toByteArray(Charsets.UTF_8))
output.flush()
}
// 读取欢迎消息
var response = reader.readLine() ?: ""
if (!response.startsWith("220")) {
return@withContext Result.failure(Exception("连接失败: $response"))
}
// EHLO
sendCommand("EHLO localhost")
response = reader.readLine() ?: ""
if (!response.startsWith("250")) {
return@withContext Result.failure(Exception("EHLO失败: $response"))
}
// MAIL FROM
sendCommand("MAIL FROM:<$from>")
response = reader.readLine() ?: ""
if (!response.startsWith("250")) {
return@withContext Result.failure(Exception("MAIL FROM失败: $response"))
}
// RCPT TO
sendCommand("RCPT TO:<$to>")
response = reader.readLine() ?: ""
if (!response.startsWith("250")) {
return@withContext Result.failure(Exception("RCPT TO失败: $response"))
}
// DATA
sendCommand("DATA")
response = reader.readLine() ?: ""
if (!response.startsWith("354")) {
return@withContext Result.failure(Exception("DATA失败: $response"))
}
// 发送邮件内容使用CRLF换行
val emailContent = buildString {
append("From: $from\r\n")
append("To: $to\r\n")
append("Subject: $subject\r\n")
append("Content-Type: text/plain; charset=UTF-8\r\n")
append("\r\n")
append(body.replace("\n", "\r\n"))
append("\r\n.\r\n")
}
output.write(emailContent.toByteArray(Charsets.UTF_8))
output.flush()
response = reader.readLine() ?: ""
if (!response.startsWith("250")) {
return@withContext Result.failure(Exception("发送失败: $response"))
}
// QUIT
sendCommand("QUIT")
try { reader.readLine() } catch (_: Exception) {}
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
} finally {
try {
socket?.close()
} catch (_: Exception) {}
}
}
}

@ -0,0 +1,501 @@
package com.example.emailclient.ui
import android.os.Bundle
import android.view.MenuItem
import android.view.View
import android.widget.ArrayAdapter
import android.widget.EditText
import android.widget.Spinner
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import com.example.emailclient.EmailApp
import com.example.emailclient.R
import com.example.emailclient.data.*
import com.example.emailclient.databinding.ActivityAdminBinding
import com.example.emailclient.network.RetrofitClient
import com.example.emailclient.ui.adapter.AdminUserAdapter
import com.example.emailclient.ui.adapter.FilterAdapter
import com.example.emailclient.ui.adapter.LogAdapter
import com.google.android.material.tabs.TabLayout
import kotlinx.coroutines.launch
class AdminActivity : AppCompatActivity() {
private lateinit var binding: ActivityAdminBinding
private var currentTab = 0
private var userAdapter: AdminUserAdapter? = null
private var filterAdapter: FilterAdapter? = null
private var logAdapter: LogAdapter? = null
private var currentLogPage = 1
private var hasMoreLogs = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityAdminBinding.inflate(layoutInflater)
setContentView(binding.root)
setSupportActionBar(binding.toolbar)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
supportActionBar?.title = getString(R.string.admin)
setupTabs()
setupRecyclerView()
setupButtons()
loadDashboard()
}
private fun setupTabs() {
binding.tabLayout.addTab(binding.tabLayout.newTab().setText(R.string.admin_dashboard))
binding.tabLayout.addTab(binding.tabLayout.newTab().setText(R.string.admin_users))
binding.tabLayout.addTab(binding.tabLayout.newTab().setText(R.string.admin_filters))
binding.tabLayout.addTab(binding.tabLayout.newTab().setText(R.string.admin_logs))
binding.tabLayout.addTab(binding.tabLayout.newTab().setText(R.string.admin_broadcast))
binding.tabLayout.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener {
override fun onTabSelected(tab: TabLayout.Tab) {
currentTab = tab.position
updateUI()
}
override fun onTabUnselected(tab: TabLayout.Tab) {}
override fun onTabReselected(tab: TabLayout.Tab) {}
})
}
private fun setupRecyclerView() {
binding.recyclerView.layoutManager = LinearLayoutManager(this)
}
private fun setupButtons() {
binding.btnAdd.setOnClickListener {
when (currentTab) {
1 -> showAddUserDialog()
2 -> showAddFilterDialog()
}
}
binding.btnClearLogs.setOnClickListener { clearLogs() }
binding.btnLoadMore.setOnClickListener { loadMoreLogs() }
binding.btnSendBroadcast.setOnClickListener { sendBroadcast() }
}
private fun updateUI() {
binding.dashboardLayout.visibility = if (currentTab == 0) View.VISIBLE else View.GONE
binding.recyclerView.visibility = if (currentTab in 1..3) View.VISIBLE else View.GONE
binding.btnAdd.visibility = if (currentTab in 1..2) View.VISIBLE else View.GONE
binding.btnClearLogs.visibility = if (currentTab == 3) View.VISIBLE else View.GONE
binding.btnLoadMore.visibility = if (currentTab == 3 && hasMoreLogs) View.VISIBLE else View.GONE
binding.broadcastLayout.visibility = if (currentTab == 4) View.VISIBLE else View.GONE
// 切换Tab时先清空RecyclerView避免显示上一个Tab的数据
binding.recyclerView.adapter = null
userAdapter = null
filterAdapter = null
logAdapter = null
when (currentTab) {
0 -> loadDashboard()
1 -> loadUsers()
2 -> loadFilters()
3 -> { currentLogPage = 1; loadLogs() }
}
}
private fun getToken(): String {
return "Bearer ${EmailApp.instance.preferencesManager.getTokenSync()}"
}
private fun setLoading(loading: Boolean) {
binding.progressBar.visibility = if (loading) View.VISIBLE else View.GONE
}
private fun loadDashboard() {
setLoading(true)
lifecycleScope.launch {
try {
val response = RetrofitClient.getApiService().getAdminDashboard(getToken())
if (response.isSuccessful && response.body()?.code == 200) {
val data = response.body()?.data!!
binding.tvUserCount.text = data.userCount.toString()
binding.tvEmailCount.text = data.emailCount.toString()
binding.tvSmtpPort.text = data.smtpPort.toString()
binding.tvPop3Port.text = data.pop3Port.toString()
} else {
Toast.makeText(this@AdminActivity, response.body()?.message ?: "加载失败", Toast.LENGTH_SHORT).show()
}
} catch (e: Exception) {
Toast.makeText(this@AdminActivity, "网络错误: ${e.message}", Toast.LENGTH_SHORT).show()
} finally {
setLoading(false)
}
}
}
private fun loadUsers() {
val requestTab = currentTab
setLoading(true)
lifecycleScope.launch {
try {
val response = RetrofitClient.getApiService().getAdminUsers(getToken())
// 检查Tab是否已切换
if (currentTab != requestTab) return@launch
if (response.isSuccessful && response.body()?.code == 200) {
val users = response.body()?.data?.users ?: emptyList()
userAdapter = AdminUserAdapter(
users.toMutableList(),
onToggle = { user -> toggleUser(user) },
onDelete = { user -> confirmDeleteUser(user) }
)
binding.recyclerView.adapter = userAdapter
} else {
Toast.makeText(this@AdminActivity, response.body()?.message ?: "加载失败", Toast.LENGTH_SHORT).show()
// 设置空adapter
userAdapter = AdminUserAdapter(mutableListOf(), {}, {})
binding.recyclerView.adapter = userAdapter
}
} catch (e: Exception) {
if (currentTab == requestTab) {
Toast.makeText(this@AdminActivity, "网络错误: ${e.message}", Toast.LENGTH_SHORT).show()
userAdapter = AdminUserAdapter(mutableListOf(), {}, {})
binding.recyclerView.adapter = userAdapter
}
} finally {
if (currentTab == requestTab) {
setLoading(false)
}
}
}
}
private fun loadFilters() {
val requestTab = currentTab
setLoading(true)
lifecycleScope.launch {
try {
val response = RetrofitClient.getApiService().getFilters(getToken())
// 检查Tab是否已切换
if (currentTab != requestTab) return@launch
if (response.isSuccessful && response.body()?.code == 200) {
val filters = response.body()?.data?.filters ?: emptyList()
filterAdapter = FilterAdapter(
filters.toMutableList(),
onDelete = { filter -> confirmDeleteFilter(filter) }
)
binding.recyclerView.adapter = filterAdapter
} else {
Toast.makeText(this@AdminActivity, response.body()?.message ?: "加载失败", Toast.LENGTH_SHORT).show()
filterAdapter = FilterAdapter(mutableListOf()) {}
binding.recyclerView.adapter = filterAdapter
}
} catch (e: Exception) {
if (currentTab == requestTab) {
Toast.makeText(this@AdminActivity, "网络错误: ${e.message}", Toast.LENGTH_SHORT).show()
filterAdapter = FilterAdapter(mutableListOf()) {}
binding.recyclerView.adapter = filterAdapter
}
} finally {
if (currentTab == requestTab) {
setLoading(false)
}
}
}
}
private fun loadLogs() {
val requestTab = currentTab
val requestPage = currentLogPage
setLoading(true)
lifecycleScope.launch {
try {
val response = RetrofitClient.getApiService().getLogs(getToken(), requestPage)
// 检查Tab是否已切换
if (currentTab != requestTab) return@launch
if (response.isSuccessful && response.body()?.code == 200) {
val data = response.body()?.data!!
hasMoreLogs = data.hasNext
binding.btnLoadMore.visibility = if (hasMoreLogs) View.VISIBLE else View.GONE
if (requestPage == 1) {
logAdapter = LogAdapter(data.logs.toMutableList())
binding.recyclerView.adapter = logAdapter
} else {
logAdapter?.addLogs(data.logs)
}
} else {
Toast.makeText(this@AdminActivity, response.body()?.message ?: "加载失败", Toast.LENGTH_SHORT).show()
if (requestPage == 1) {
logAdapter = LogAdapter(mutableListOf())
binding.recyclerView.adapter = logAdapter
}
}
} catch (e: Exception) {
if (currentTab == requestTab) {
Toast.makeText(this@AdminActivity, "网络错误: ${e.message}", Toast.LENGTH_SHORT).show()
if (requestPage == 1) {
logAdapter = LogAdapter(mutableListOf())
binding.recyclerView.adapter = logAdapter
}
}
} finally {
if (currentTab == requestTab) {
setLoading(false)
}
}
}
}
private fun loadMoreLogs() {
currentLogPage++
loadLogs()
}
private fun showAddUserDialog() {
val dialogView = layoutInflater.inflate(R.layout.dialog_add_user, null)
val etUsername = dialogView.findViewById<EditText>(R.id.etUsername)
val etEmail = dialogView.findViewById<EditText>(R.id.etEmail)
val etPassword = dialogView.findViewById<EditText>(R.id.etPassword)
val cbIsAdmin = dialogView.findViewById<android.widget.CheckBox>(R.id.cbIsAdmin)
AlertDialog.Builder(this)
.setTitle(R.string.add_user)
.setView(dialogView)
.setPositiveButton(R.string.save) { _, _ ->
val username = etUsername.text.toString().trim()
val email = etEmail.text.toString().trim()
val password = etPassword.text.toString()
val isAdmin = cbIsAdmin.isChecked
if (username.isNotEmpty() && email.isNotEmpty() && password.isNotEmpty()) {
addUser(username, email, password, isAdmin)
} else {
Toast.makeText(this, "请填写完整信息", Toast.LENGTH_SHORT).show()
}
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
private fun addUser(username: String, email: String, password: String, isAdmin: Boolean) {
setLoading(true)
lifecycleScope.launch {
try {
val response = RetrofitClient.getApiService().addAdminUser(
getToken(),
AddUserRequest(username, email, password, isAdmin)
)
if (response.isSuccessful && response.body()?.code == 200) {
Toast.makeText(this@AdminActivity, "用户添加成功", Toast.LENGTH_SHORT).show()
loadUsers()
} else {
Toast.makeText(this@AdminActivity, response.body()?.message ?: "添加失败", Toast.LENGTH_SHORT).show()
}
} catch (e: Exception) {
Toast.makeText(this@AdminActivity, "网络错误: ${e.message}", Toast.LENGTH_SHORT).show()
} finally {
setLoading(false)
}
}
}
private fun toggleUser(user: AdminUser) {
setLoading(true)
lifecycleScope.launch {
try {
val response = RetrofitClient.getApiService().toggleUser(getToken(), user.id)
if (response.isSuccessful && response.body()?.code == 200) {
Toast.makeText(this@AdminActivity, "用户状态已更新", Toast.LENGTH_SHORT).show()
loadUsers()
} else {
Toast.makeText(this@AdminActivity, response.body()?.message ?: "操作失败", Toast.LENGTH_SHORT).show()
}
} catch (e: Exception) {
Toast.makeText(this@AdminActivity, "网络错误: ${e.message}", Toast.LENGTH_SHORT).show()
} finally {
setLoading(false)
}
}
}
private fun confirmDeleteUser(user: AdminUser) {
AlertDialog.Builder(this)
.setTitle("删除用户")
.setMessage("确定要删除用户 ${user.username} 吗?")
.setPositiveButton(R.string.delete) { _, _ -> deleteUser(user) }
.setNegativeButton(android.R.string.cancel, null)
.show()
}
private fun deleteUser(user: AdminUser) {
setLoading(true)
lifecycleScope.launch {
try {
val response = RetrofitClient.getApiService().deleteUser(getToken(), user.id)
if (response.isSuccessful && response.body()?.code == 200) {
Toast.makeText(this@AdminActivity, "用户已删除", Toast.LENGTH_SHORT).show()
loadUsers()
} else {
Toast.makeText(this@AdminActivity, response.body()?.message ?: "删除失败", Toast.LENGTH_SHORT).show()
}
} catch (e: Exception) {
Toast.makeText(this@AdminActivity, "网络错误: ${e.message}", Toast.LENGTH_SHORT).show()
} finally {
setLoading(false)
}
}
}
private fun showAddFilterDialog() {
val dialogView = layoutInflater.inflate(R.layout.dialog_add_filter, null)
val spinnerType = dialogView.findViewById<Spinner>(R.id.spinnerType)
val etValue = dialogView.findViewById<EditText>(R.id.etValue)
val spinnerAction = dialogView.findViewById<Spinner>(R.id.spinnerAction)
spinnerType.adapter = ArrayAdapter(this, android.R.layout.simple_spinner_dropdown_item, listOf("邮箱地址", "IP地址"))
spinnerAction.adapter = ArrayAdapter(this, android.R.layout.simple_spinner_dropdown_item, listOf("阻止", "允许"))
AlertDialog.Builder(this)
.setTitle(R.string.add_filter)
.setView(dialogView)
.setPositiveButton(R.string.save) { _, _ ->
val filterType = if (spinnerType.selectedItemPosition == 0) "email" else "ip"
val value = etValue.text.toString().trim()
val action = if (spinnerAction.selectedItemPosition == 0) "block" else "allow"
if (value.isNotEmpty()) {
addFilter(filterType, value, action)
} else {
Toast.makeText(this, "请输入过滤值", Toast.LENGTH_SHORT).show()
}
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
private fun addFilter(filterType: String, value: String, action: String) {
setLoading(true)
lifecycleScope.launch {
try {
val response = RetrofitClient.getApiService().addFilter(
getToken(),
AddFilterRequest(filterType, value, action)
)
if (response.isSuccessful && response.body()?.code == 200) {
Toast.makeText(this@AdminActivity, "过滤规则添加成功", Toast.LENGTH_SHORT).show()
loadFilters()
} else {
Toast.makeText(this@AdminActivity, response.body()?.message ?: "添加失败", Toast.LENGTH_SHORT).show()
}
} catch (e: Exception) {
Toast.makeText(this@AdminActivity, "网络错误: ${e.message}", Toast.LENGTH_SHORT).show()
} finally {
setLoading(false)
}
}
}
private fun confirmDeleteFilter(filter: EmailFilter) {
AlertDialog.Builder(this)
.setTitle("删除过滤规则")
.setMessage("确定要删除此过滤规则吗?")
.setPositiveButton(R.string.delete) { _, _ -> deleteFilter(filter) }
.setNegativeButton(android.R.string.cancel, null)
.show()
}
private fun deleteFilter(filter: EmailFilter) {
setLoading(true)
lifecycleScope.launch {
try {
val response = RetrofitClient.getApiService().deleteFilter(getToken(), filter.id)
if (response.isSuccessful && response.body()?.code == 200) {
Toast.makeText(this@AdminActivity, "过滤规则已删除", Toast.LENGTH_SHORT).show()
loadFilters()
} else {
Toast.makeText(this@AdminActivity, response.body()?.message ?: "删除失败", Toast.LENGTH_SHORT).show()
}
} catch (e: Exception) {
Toast.makeText(this@AdminActivity, "网络错误: ${e.message}", Toast.LENGTH_SHORT).show()
} finally {
setLoading(false)
}
}
}
private fun clearLogs() {
AlertDialog.Builder(this)
.setTitle("清空日志")
.setMessage("确定要清空所有日志吗?")
.setPositiveButton("确定") { _, _ ->
setLoading(true)
lifecycleScope.launch {
try {
val response = RetrofitClient.getApiService().clearLogs(getToken())
if (response.isSuccessful && response.body()?.code == 200) {
Toast.makeText(this@AdminActivity, "日志已清空", Toast.LENGTH_SHORT).show()
currentLogPage = 1
loadLogs()
}
} catch (e: Exception) {
Toast.makeText(this@AdminActivity, "网络错误: ${e.message}", Toast.LENGTH_SHORT).show()
} finally {
setLoading(false)
}
}
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
private fun sendBroadcast() {
val subject = binding.etBroadcastSubject.text.toString().trim()
val body = binding.etBroadcastBody.text.toString().trim()
if (subject.isEmpty() || body.isEmpty()) {
Toast.makeText(this, "主题和内容不能为空", Toast.LENGTH_SHORT).show()
return
}
AlertDialog.Builder(this)
.setTitle("确认发送")
.setMessage("确定要向所有用户发送群发邮件吗?")
.setPositiveButton("发送") { _, _ ->
setLoading(true)
lifecycleScope.launch {
try {
val response = RetrofitClient.getApiService().broadcast(
getToken(),
BroadcastRequest(subject, body)
)
if (response.isSuccessful && response.body()?.code == 200) {
val count = response.body()?.data?.sentCount ?: 0
Toast.makeText(this@AdminActivity, "已向 $count 个用户发送邮件", Toast.LENGTH_SHORT).show()
binding.etBroadcastSubject.text?.clear()
binding.etBroadcastBody.text?.clear()
} else {
Toast.makeText(this@AdminActivity, response.body()?.message ?: "发送失败", Toast.LENGTH_SHORT).show()
}
} catch (e: Exception) {
Toast.makeText(this@AdminActivity, "网络错误: ${e.message}", Toast.LENGTH_SHORT).show()
} finally {
setLoading(false)
}
}
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == android.R.id.home) {
finish()
return true
}
return super.onOptionsItemSelected(item)
}
}

@ -0,0 +1,172 @@
package com.example.emailclient.ui
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import com.example.emailclient.EmailApp
import com.example.emailclient.databinding.ActivityComposeBinding
import kotlinx.coroutines.launch
/**
* 写邮件界面 - 支持本地存储草稿和发件箱
*/
class ComposeActivity : AppCompatActivity() {
private lateinit var binding: ActivityComposeBinding
private val prefs by lazy { EmailApp.instance.preferencesManager }
private val repository by lazy { EmailApp.instance.emailRepository }
private var draftId: Long = 0 // 如果是编辑草稿保存草稿ID
private var isEditingDraft = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityComposeBinding.inflate(layoutInflater)
setContentView(binding.root)
setSupportActionBar(binding.toolbar)
binding.toolbar.setNavigationOnClickListener {
// 返回时提示保存草稿
checkSaveDraftOnExit()
}
// 检查是否是编辑草稿
draftId = intent.getLongExtra("draft_id", 0)
if (draftId > 0) {
isEditingDraft = true
binding.toolbar.title = "编辑草稿"
binding.etRecipient.setText(intent.getStringExtra("recipient") ?: "")
binding.etSubject.setText(intent.getStringExtra("subject") ?: "")
binding.etBody.setText(intent.getStringExtra("body") ?: "")
}
// 如果是回复邮件
intent.getStringExtra("reply_to")?.let { replyTo ->
binding.etRecipient.setText(replyTo)
intent.getStringExtra("subject")?.let { subject ->
if (!subject.startsWith("Re:")) {
binding.etSubject.setText("Re: $subject")
} else {
binding.etSubject.setText(subject)
}
}
}
binding.btnSend.setOnClickListener { sendEmail() }
binding.btnSaveDraft.setOnClickListener { saveDraft() }
}
/**
* 发送邮件保存到本地发件箱
*/
private fun sendEmail() {
val recipient = binding.etRecipient.text.toString().trim()
val subject = binding.etSubject.text.toString().trim()
val body = binding.etBody.text.toString()
if (recipient.isEmpty()) {
binding.tilRecipient.error = "请输入收件人邮箱"
return
}
if (!recipient.contains("@")) {
binding.tilRecipient.error = "请输入有效的邮箱地址"
return
}
binding.tilRecipient.error = null
val from = prefs.getEmailSync() ?: "user@example.com"
setLoading(true)
lifecycleScope.launch {
try {
// 保存到发件箱
repository.saveSentEmail(from, recipient, subject, body)
// 如果是从草稿发送,删除草稿
if (isEditingDraft && draftId > 0) {
repository.deleteEmail(draftId)
}
Toast.makeText(this@ComposeActivity, "邮件已发送", Toast.LENGTH_SHORT).show()
finish()
} catch (e: Exception) {
setLoading(false)
Toast.makeText(this@ComposeActivity, "发送失败: ${e.message}", Toast.LENGTH_LONG).show()
}
}
}
/**
* 保存草稿
*/
private fun saveDraft() {
val recipient = binding.etRecipient.text.toString().trim()
val subject = binding.etSubject.text.toString().trim()
val body = binding.etBody.text.toString()
// 草稿可以不填收件人
val from = prefs.getEmailSync() ?: "user@example.com"
setLoading(true)
lifecycleScope.launch {
try {
if (isEditingDraft && draftId > 0) {
// 更新现有草稿
repository.updateDraft(draftId, recipient, subject, body)
Toast.makeText(this@ComposeActivity, "草稿已更新", Toast.LENGTH_SHORT).show()
} else {
// 创建新草稿
repository.saveDraft(from, recipient, subject, body)
Toast.makeText(this@ComposeActivity, "草稿已保存", Toast.LENGTH_SHORT).show()
}
finish()
} catch (e: Exception) {
setLoading(false)
Toast.makeText(this@ComposeActivity, "保存失败: ${e.message}", Toast.LENGTH_LONG).show()
}
}
}
/**
* 退出时检查是否需要保存草稿
*/
private fun checkSaveDraftOnExit() {
val recipient = binding.etRecipient.text.toString().trim()
val subject = binding.etSubject.text.toString().trim()
val body = binding.etBody.text.toString()
// 如果有内容,提示保存草稿
if (recipient.isNotEmpty() || subject.isNotEmpty() || body.isNotEmpty()) {
androidx.appcompat.app.AlertDialog.Builder(this)
.setTitle("保存草稿")
.setMessage("是否保存为草稿?")
.setPositiveButton("保存") { _, _ ->
saveDraft()
}
.setNegativeButton("不保存") { _, _ ->
finish()
}
.setNeutralButton("取消", null)
.show()
} else {
finish()
}
}
private fun setLoading(loading: Boolean) {
binding.progressBar.visibility = if (loading) View.VISIBLE else View.GONE
binding.btnSend.isEnabled = !loading
binding.btnSaveDraft.isEnabled = !loading
}
@Deprecated("Deprecated in Java")
override fun onBackPressed() {
checkSaveDraftOnExit()
}
}

@ -0,0 +1,147 @@
package com.example.emailclient.ui
import android.content.Intent
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import com.example.emailclient.EmailApp
import com.example.emailclient.data.UpdateContactRequest
import com.example.emailclient.databinding.ActivityContactDetailBinding
import com.example.emailclient.network.RetrofitClient
import com.example.emailclient.ui.adapter.ContactEmailAdapter
import kotlinx.coroutines.launch
class ContactDetailActivity : AppCompatActivity() {
private lateinit var binding: ActivityContactDetailBinding
private lateinit var adapter: ContactEmailAdapter
private val prefs by lazy { EmailApp.instance.preferencesManager }
private var contactId: Int = 0
private var contactEmail: String = ""
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityContactDetailBinding.inflate(layoutInflater)
setContentView(binding.root)
setSupportActionBar(binding.toolbar)
binding.toolbar.setNavigationOnClickListener { finish() }
contactId = intent.getIntExtra("contact_id", 0)
val name = intent.getStringExtra("contact_name") ?: ""
contactEmail = intent.getStringExtra("contact_email") ?: ""
val note = intent.getStringExtra("contact_note") ?: ""
// 设置初始值
binding.etName.setText(name)
binding.tvEmail.text = "邮箱: $contactEmail"
binding.etNote.setText(note)
binding.toolbar.title = name
setupRecyclerView()
setupListeners()
loadContactEmails()
}
private fun setupRecyclerView() {
adapter = ContactEmailAdapter { email ->
val intent = Intent(this, EmailDetailActivity::class.java).apply {
putExtra("from", email.senderAddress)
putExtra("to", email.recipientAddress)
putExtra("subject", email.subject)
putExtra("body", email.body)
}
startActivity(intent)
}
binding.recyclerView.layoutManager = LinearLayoutManager(this)
binding.recyclerView.adapter = adapter
}
private fun setupListeners() {
binding.btnSave.setOnClickListener { saveContact() }
binding.fabCompose.setOnClickListener {
val intent = Intent(this, ComposeActivity::class.java).apply {
putExtra("reply_to", contactEmail)
}
startActivity(intent)
}
}
private fun saveContact() {
val name = binding.etName.text.toString().trim()
val note = binding.etNote.text.toString().trim()
if (name.isEmpty()) {
binding.tilName.error = "姓名不能为空"
return
}
binding.tilName.error = null
val token = prefs.getTokenSync() ?: return
setLoading(true)
lifecycleScope.launch {
try {
val response = RetrofitClient.getApiService().updateContact(
"Bearer $token",
contactId,
UpdateContactRequest(name, note)
)
if (response.isSuccessful && response.body()?.code == 200) {
Toast.makeText(this@ContactDetailActivity, "保存成功", Toast.LENGTH_SHORT).show()
binding.toolbar.title = name
} else {
Toast.makeText(this@ContactDetailActivity,
response.body()?.message ?: "保存失败", Toast.LENGTH_SHORT).show()
}
} catch (e: Exception) {
Toast.makeText(this@ContactDetailActivity,
"网络错误: ${e.message}", Toast.LENGTH_SHORT).show()
} finally {
setLoading(false)
}
}
}
private fun loadContactEmails() {
val token = prefs.getTokenSync() ?: return
setLoading(true)
lifecycleScope.launch {
try {
val response = RetrofitClient.getApiService().getContactEmails(
"Bearer $token",
contactId
)
if (response.isSuccessful && response.body()?.code == 200) {
val emails = response.body()?.data?.emails ?: emptyList()
adapter.submitList(emails)
binding.tvEmpty.visibility = if (emails.isEmpty()) View.VISIBLE else View.GONE
} else {
Toast.makeText(this@ContactDetailActivity,
response.body()?.message ?: "获取邮件失败", Toast.LENGTH_SHORT).show()
}
} catch (e: Exception) {
Toast.makeText(this@ContactDetailActivity,
"网络错误: ${e.message}", Toast.LENGTH_SHORT).show()
} finally {
setLoading(false)
}
}
}
private fun setLoading(loading: Boolean) {
binding.progressBar.visibility = if (loading) View.VISIBLE else View.GONE
}
override fun onResume() {
super.onResume()
loadContactEmails()
}
}

@ -0,0 +1,174 @@
package com.example.emailclient.ui
import android.content.Intent
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import com.example.emailclient.EmailApp
import com.example.emailclient.data.AddContactRequest
import com.example.emailclient.databinding.ActivityContactsBinding
import com.example.emailclient.databinding.DialogAddContactBinding
import com.example.emailclient.network.RetrofitClient
import com.example.emailclient.ui.adapter.ContactAdapter
import kotlinx.coroutines.launch
class ContactsActivity : AppCompatActivity() {
private lateinit var binding: ActivityContactsBinding
private lateinit var adapter: ContactAdapter
private val prefs by lazy { EmailApp.instance.preferencesManager }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityContactsBinding.inflate(layoutInflater)
setContentView(binding.root)
setSupportActionBar(binding.toolbar)
binding.toolbar.setNavigationOnClickListener { finish() }
setupRecyclerView()
binding.fabAdd.setOnClickListener { showAddDialog() }
loadContacts()
}
private fun setupRecyclerView() {
adapter = ContactAdapter(
onClick = { contact ->
// 点击联系人,跳转到详情页(查看邮件往来、编辑)
val intent = Intent(this, ContactDetailActivity::class.java).apply {
putExtra("contact_id", contact.id)
putExtra("contact_name", contact.name)
putExtra("contact_email", contact.email)
putExtra("contact_note", contact.note)
}
startActivity(intent)
},
onDelete = { contact ->
AlertDialog.Builder(this)
.setTitle("删除联系人")
.setMessage("确定要删除 ${contact.name} 吗?")
.setPositiveButton("删除") { _, _ -> deleteContact(contact.id) }
.setNegativeButton("取消", null)
.show()
}
)
binding.recyclerView.layoutManager = LinearLayoutManager(this)
binding.recyclerView.adapter = adapter
}
private fun loadContacts() {
val token = prefs.getTokenSync()
if (token.isNullOrEmpty()) {
Toast.makeText(this, "未登录,请先登录", Toast.LENGTH_SHORT).show()
return
}
setLoading(true)
lifecycleScope.launch {
try {
val response = RetrofitClient.getApiService().getContacts("Bearer $token")
if (response.isSuccessful && response.body()?.code == 200) {
val contacts = response.body()?.data?.contacts ?: emptyList()
adapter.submitList(contacts)
binding.tvEmpty.visibility = if (contacts.isEmpty()) View.VISIBLE else View.GONE
} else if (response.code() == 401 || response.body()?.code == 401) {
// Token失效跳转登录
Toast.makeText(this@ContactsActivity, "登录已过期,请重新登录", Toast.LENGTH_SHORT).show()
EmailApp.instance.preferencesManager.clearUserInfo()
val intent = Intent(this@ContactsActivity, LoginActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
startActivity(intent)
finish()
} else {
val errorMsg = response.body()?.message ?: "获取失败 (${response.code()})"
Toast.makeText(this@ContactsActivity, errorMsg, Toast.LENGTH_SHORT).show()
}
} catch (e: Exception) {
e.printStackTrace()
Toast.makeText(this@ContactsActivity,
"网络错误: ${e.javaClass.simpleName} - ${e.message}", Toast.LENGTH_LONG).show()
} finally {
setLoading(false)
}
}
}
private fun showAddDialog() {
val dialogBinding = DialogAddContactBinding.inflate(layoutInflater)
AlertDialog.Builder(this)
.setTitle("添加联系人")
.setView(dialogBinding.root)
.setPositiveButton("添加") { _, _ ->
val name = dialogBinding.etName.text.toString().trim()
val email = dialogBinding.etEmail.text.toString().trim()
val note = dialogBinding.etNote.text.toString().trim()
if (name.isNotEmpty() && email.isNotEmpty()) {
addContact(name, email, note)
} else {
Toast.makeText(this, "请填写姓名和邮箱", Toast.LENGTH_SHORT).show()
}
}
.setNegativeButton("取消", null)
.show()
}
private fun addContact(name: String, email: String, note: String) {
val token = prefs.getTokenSync() ?: return
lifecycleScope.launch {
try {
val response = RetrofitClient.getApiService().addContact(
"Bearer $token",
AddContactRequest(name, email, note)
)
if (response.isSuccessful && response.body()?.code == 200) {
Toast.makeText(this@ContactsActivity, "添加成功", Toast.LENGTH_SHORT).show()
loadContacts()
} else {
Toast.makeText(this@ContactsActivity,
response.body()?.message ?: "添加失败", Toast.LENGTH_SHORT).show()
}
} catch (e: Exception) {
Toast.makeText(this@ContactsActivity,
"网络错误: ${e.message}", Toast.LENGTH_SHORT).show()
}
}
}
private fun deleteContact(id: Int) {
val token = prefs.getTokenSync() ?: return
lifecycleScope.launch {
try {
val response = RetrofitClient.getApiService().deleteContact("Bearer $token", id)
if (response.isSuccessful && response.body()?.code == 200) {
Toast.makeText(this@ContactsActivity, "删除成功", Toast.LENGTH_SHORT).show()
loadContacts()
} else {
Toast.makeText(this@ContactsActivity,
response.body()?.message ?: "删除失败", Toast.LENGTH_SHORT).show()
}
} catch (e: Exception) {
Toast.makeText(this@ContactsActivity,
"网络错误: ${e.message}", Toast.LENGTH_SHORT).show()
}
}
}
private fun setLoading(loading: Boolean) {
binding.progressBar.visibility = if (loading) View.VISIBLE else View.GONE
}
override fun onResume() {
super.onResume()
loadContacts()
}
}

@ -0,0 +1,120 @@
package com.example.emailclient.ui
import android.content.Intent
import android.os.Bundle
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import com.example.emailclient.EmailApp
import com.example.emailclient.data.StarByInfoRequest
import com.example.emailclient.databinding.ActivityEmailDetailBinding
import com.example.emailclient.network.RetrofitClient
import kotlinx.coroutines.launch
/**
* 邮件详情界面 - 显示通过POP3获取的邮件内容
*/
class EmailDetailActivity : AppCompatActivity() {
private lateinit var binding: ActivityEmailDetailBinding
private var isStarred = false
private var senderAddress = ""
private var emailSubject = ""
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityEmailDetailBinding.inflate(layoutInflater)
setContentView(binding.root)
setSupportActionBar(binding.toolbar)
binding.toolbar.setNavigationOnClickListener { finish() }
senderAddress = intent.getStringExtra("from") ?: ""
val to = intent.getStringExtra("to") ?: ""
emailSubject = intent.getStringExtra("subject") ?: "(无主题)"
val body = intent.getStringExtra("body") ?: ""
binding.tvFrom.text = senderAddress.ifEmpty { "未知发件人" }
binding.tvTo.text = to.ifEmpty { "未知收件人" }
binding.tvSubject.text = emailSubject
binding.tvBody.text = body
binding.tvDate.text = "通过POP3获取"
// 检查星标状态
checkStarredStatus()
// 星标按钮
binding.btnStar.setOnClickListener { toggleStar() }
// 回复按钮 - 通过SMTP发送回复
binding.fabReply.setOnClickListener {
val intent = Intent(this, ComposeActivity::class.java).apply {
putExtra("reply_to", senderAddress)
putExtra("subject", emailSubject)
}
startActivity(intent)
}
}
private fun updateStarButton() {
binding.btnStar.text = if (isStarred) "⭐ 已星标" else "☆ 星标"
}
private fun checkStarredStatus() {
val token = EmailApp.instance.preferencesManager.getTokenSync() ?: return
lifecycleScope.launch {
try {
val response = RetrofitClient.getApiService().checkStarred(
"Bearer $token",
StarByInfoRequest(senderAddress, emailSubject)
)
if (response.isSuccessful && response.body()?.code == 200) {
val data = response.body()?.data
if (data != null && data.found) {
isStarred = data.isStarred
updateStarButton()
}
}
} catch (e: Exception) {
// 忽略错误,保持默认状态
}
}
}
private fun toggleStar() {
val token = EmailApp.instance.preferencesManager.getTokenSync() ?: return
lifecycleScope.launch {
try {
val response = RetrofitClient.getApiService().toggleStarByInfo(
"Bearer $token",
StarByInfoRequest(senderAddress, emailSubject)
)
if (response.isSuccessful && response.body()?.code == 200) {
val data = response.body()?.data
if (data != null) {
isStarred = data.isStarred
updateStarButton()
Toast.makeText(
this@EmailDetailActivity,
if (isStarred) "已添加星标" else "已取消星标",
Toast.LENGTH_SHORT
).show()
}
} else if (response.code() == 401 || response.body()?.code == 401) {
Toast.makeText(this@EmailDetailActivity, "登录已过期,请重新登录", Toast.LENGTH_SHORT).show()
EmailApp.instance.preferencesManager.clearUserInfo()
startActivity(Intent(this@EmailDetailActivity, LoginActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
})
finish()
} else {
Toast.makeText(this@EmailDetailActivity, "操作失败", Toast.LENGTH_SHORT).show()
}
} catch (e: Exception) {
Toast.makeText(this@EmailDetailActivity, "网络错误", Toast.LENGTH_SHORT).show()
}
}
}
}

@ -0,0 +1,123 @@
package com.example.emailclient.ui
import android.content.Intent
import android.os.Bundle
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import com.example.emailclient.EmailApp
import com.example.emailclient.data.local.LocalEmail
import com.example.emailclient.databinding.ActivityLocalEmailDetailBinding
import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.util.*
class LocalEmailDetailActivity : AppCompatActivity() {
private lateinit var binding: ActivityLocalEmailDetailBinding
private val repository by lazy { EmailApp.instance.emailRepository }
private var emailId: Long = 0
private var email: LocalEmail? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityLocalEmailDetailBinding.inflate(layoutInflater)
setContentView(binding.root)
setSupportActionBar(binding.toolbar)
binding.toolbar.setNavigationOnClickListener { finish() }
emailId = intent.getLongExtra("email_id", 0)
if (emailId == 0L) {
Toast.makeText(this, "邮件不存在", Toast.LENGTH_SHORT).show()
finish()
return
}
loadEmail()
setupButtons()
}
private fun loadEmail() {
lifecycleScope.launch {
email = repository.getEmailById(emailId)
email?.let { displayEmail(it) } ?: run {
Toast.makeText(this@LocalEmailDetailActivity, "邮件不存在", Toast.LENGTH_SHORT).show()
finish()
}
}
}
private fun displayEmail(email: LocalEmail) {
binding.tvSubject.text = email.subject.ifEmpty { "(无主题)" }
binding.tvFrom.text = "发件人: ${email.senderAddress}"
binding.tvTo.text = "收件人: ${email.recipientAddress}"
binding.tvDate.text = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
.format(Date(email.createdAt))
binding.tvBody.text = email.body
updateStarButton(email.isStarred)
}
private fun updateStarButton(isStarred: Boolean) {
binding.btnStar.setImageResource(
if (isStarred) android.R.drawable.btn_star_big_on
else android.R.drawable.btn_star_big_off
)
}
private fun setupButtons() {
binding.btnStar.setOnClickListener {
toggleStar()
}
binding.btnReply.setOnClickListener {
email?.let {
val intent = Intent(this, ComposeActivity::class.java).apply {
putExtra("reply_to", it.senderAddress)
putExtra("subject", it.subject)
}
startActivity(intent)
}
}
binding.btnDelete.setOnClickListener {
showDeleteDialog()
}
}
private fun toggleStar() {
lifecycleScope.launch {
repository.toggleStar(emailId)
email = repository.getEmailById(emailId)
email?.let {
updateStarButton(it.isStarred)
Toast.makeText(
this@LocalEmailDetailActivity,
if (it.isStarred) "已添加星标" else "已取消星标",
Toast.LENGTH_SHORT
).show()
}
}
}
private fun showDeleteDialog() {
AlertDialog.Builder(this)
.setTitle("删除邮件")
.setMessage("确定要删除这封邮件吗?")
.setPositiveButton("删除") { _, _ ->
deleteEmail()
}
.setNegativeButton("取消", null)
.show()
}
private fun deleteEmail() {
lifecycleScope.launch {
repository.deleteEmail(emailId)
Toast.makeText(this@LocalEmailDetailActivity, "已删除", Toast.LENGTH_SHORT).show()
finish()
}
}
}

@ -0,0 +1,82 @@
package com.example.emailclient.ui
import android.content.Intent
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import com.example.emailclient.EmailApp
import com.example.emailclient.data.LoginRequest
import com.example.emailclient.databinding.ActivityLoginBinding
import com.example.emailclient.network.RetrofitClient
import kotlinx.coroutines.launch
class LoginActivity : AppCompatActivity() {
private lateinit var binding: ActivityLoginBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityLoginBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.btnLogin.setOnClickListener { login() }
binding.btnRegister.setOnClickListener {
startActivity(Intent(this, RegisterActivity::class.java))
}
binding.btnSettings.setOnClickListener {
startActivity(Intent(this, SettingsActivity::class.java))
}
}
private fun login() {
val username = binding.etUsername.text.toString().trim()
val password = binding.etPassword.text.toString()
if (username.isEmpty()) {
binding.tilUsername.error = "请输入用户名"
return
}
if (password.isEmpty()) {
binding.tilPassword.error = "请输入密码"
return
}
binding.tilUsername.error = null
binding.tilPassword.error = null
setLoading(true)
lifecycleScope.launch {
try {
val response = RetrofitClient.getApiService().login(LoginRequest(username, password))
if (response.isSuccessful && response.body()?.code == 200) {
val data = response.body()?.data!!
EmailApp.instance.preferencesManager.saveUserInfo(
data.token,
data.user.username,
data.user.email,
data.user.isAdmin
)
startActivity(Intent(this@LoginActivity, MainActivity::class.java))
finish()
} else {
Toast.makeText(this@LoginActivity,
response.body()?.message ?: "登录失败", Toast.LENGTH_SHORT).show()
}
} catch (e: Exception) {
Toast.makeText(this@LoginActivity,
"网络错误: ${e.message}", Toast.LENGTH_SHORT).show()
} finally {
setLoading(false)
}
}
}
private fun setLoading(loading: Boolean) {
binding.progressBar.visibility = if (loading) View.VISIBLE else View.GONE
binding.btnLogin.isEnabled = !loading
binding.btnRegister.isEnabled = !loading
}
}

@ -0,0 +1,251 @@
package com.example.emailclient.ui
import android.content.Intent
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import com.example.emailclient.EmailApp
import com.example.emailclient.R
import com.example.emailclient.data.local.LocalEmail
import com.example.emailclient.databinding.ActivityMainBinding
import com.example.emailclient.ui.adapter.LocalEmailAdapter
import com.google.android.material.tabs.TabLayout
import kotlinx.coroutines.launch
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
private lateinit var adapter: LocalEmailAdapter
private val repository by lazy { EmailApp.instance.emailRepository }
private var currentTab = TAB_INBOX
private var isAdmin = false
companion object {
const val TAB_INBOX = 0
const val TAB_SENT = 1
const val TAB_DRAFT = 2
const val TAB_STARRED = 3
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
setSupportActionBar(binding.toolbar)
setupTabs()
setupRecyclerView()
isAdmin = EmailApp.instance.preferencesManager.isAdminSync()
binding.swipeRefresh.setOnRefreshListener { loadEmails() }
binding.fabCompose.setOnClickListener {
startActivity(Intent(this, ComposeActivity::class.java))
}
loadEmails()
}
private fun setupTabs() {
binding.tabLayout.addTab(binding.tabLayout.newTab().setText("收件箱"))
binding.tabLayout.addTab(binding.tabLayout.newTab().setText("发件箱"))
binding.tabLayout.addTab(binding.tabLayout.newTab().setText("草稿箱"))
binding.tabLayout.addTab(binding.tabLayout.newTab().setText("星标"))
binding.tabLayout.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener {
override fun onTabSelected(tab: TabLayout.Tab) {
currentTab = tab.position
updateToolbarTitle()
loadEmails()
}
override fun onTabUnselected(tab: TabLayout.Tab) {}
override fun onTabReselected(tab: TabLayout.Tab) {}
})
}
private fun updateToolbarTitle() {
binding.toolbar.title = when (currentTab) {
TAB_INBOX -> "收件箱"
TAB_SENT -> "发件箱"
TAB_DRAFT -> "草稿箱"
TAB_STARRED -> "星标邮件"
else -> "邮件"
}
}
private fun setupRecyclerView() {
adapter = LocalEmailAdapter(
onClick = { email -> openEmail(email) },
onLongClick = { email -> showDeleteDialog(email) },
onStarClick = { email -> toggleStar(email) }
)
binding.recyclerView.layoutManager = LinearLayoutManager(this)
binding.recyclerView.adapter = adapter
}
private fun openEmail(email: LocalEmail) {
if (email.isDraft) {
// 草稿打开编辑界面
val intent = Intent(this, ComposeActivity::class.java).apply {
putExtra("draft_id", email.id)
putExtra("recipient", email.recipientAddress)
putExtra("subject", email.subject)
putExtra("body", email.body)
}
startActivity(intent)
} else {
// 其他邮件打开详情
val intent = Intent(this, LocalEmailDetailActivity::class.java).apply {
putExtra("email_id", email.id)
}
startActivity(intent)
// 标记已读
lifecycleScope.launch {
repository.markAsRead(email.id)
}
}
}
private fun showDeleteDialog(email: LocalEmail) {
AlertDialog.Builder(this)
.setTitle("删除邮件")
.setMessage("确定要删除这封邮件吗?\n\n主题: ${email.subject.ifEmpty { "(无主题)" }}")
.setPositiveButton("删除") { _, _ -> deleteEmail(email) }
.setNegativeButton("取消", null)
.show()
}
private fun deleteEmail(email: LocalEmail) {
lifecycleScope.launch {
repository.deleteEmail(email.id)
Toast.makeText(this@MainActivity, "已删除", Toast.LENGTH_SHORT).show()
loadEmails()
}
}
private fun toggleStar(email: LocalEmail) {
lifecycleScope.launch {
repository.toggleStar(email.id)
loadEmails()
Toast.makeText(
this@MainActivity,
if (email.isStarred) "已取消星标" else "已添加星标",
Toast.LENGTH_SHORT
).show()
}
}
private fun loadEmails() {
setLoading(true)
lifecycleScope.launch {
val emails = when (currentTab) {
TAB_INBOX -> repository.getInboxEmails()
TAB_SENT -> repository.getSentEmails()
TAB_DRAFT -> repository.getDraftEmails()
TAB_STARRED -> repository.getStarredEmails()
else -> emptyList()
}
binding.swipeRefresh.isRefreshing = false
setLoading(false)
adapter.submitList(emails)
val emptyText = when (currentTab) {
TAB_INBOX -> "暂无收件"
TAB_SENT -> "暂无已发送邮件"
TAB_DRAFT -> "暂无草稿"
TAB_STARRED -> "暂无星标邮件"
else -> "暂无邮件"
}
binding.tvEmpty.text = emptyText
binding.tvEmpty.visibility = if (emails.isEmpty()) View.VISIBLE else View.GONE
}
}
private fun setLoading(loading: Boolean) {
binding.progressBar.visibility = if (loading) View.VISIBLE else View.GONE
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.main_menu, menu)
menu.findItem(R.id.action_admin)?.isVisible = isAdmin
// 隐藏星标菜单项因为已经有Tab了
menu.findItem(R.id.action_starred)?.isVisible = false
return true
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.action_contacts -> {
startActivity(Intent(this, ContactsActivity::class.java))
true
}
R.id.action_admin -> {
startActivity(Intent(this, AdminActivity::class.java))
true
}
R.id.action_settings -> {
startActivity(Intent(this, SettingsActivity::class.java))
true
}
R.id.action_test_data -> {
addTestData()
true
}
else -> super.onOptionsItemSelected(item)
}
}
// 添加测试数据
private fun addTestData() {
val userEmail = EmailApp.instance.preferencesManager.getEmailSync() ?: "user@example.com"
lifecycleScope.launch {
// 添加收件箱测试邮件
repository.saveInboxEmail(
from = "sender@example.com",
to = userEmail,
subject = "欢迎使用邮件客户端",
body = "这是一封测试邮件,欢迎使用本地邮件客户端!\n\n您可以在这里管理您的邮件。"
)
repository.saveInboxEmail(
from = "news@example.com",
to = userEmail,
subject = "今日新闻摘要",
body = "今日头条新闻:\n1. 科技发展日新月异\n2. 天气预报显示明天晴朗\n3. 体育赛事精彩纷呈"
)
// 添加发件箱测试邮件
repository.saveSentEmail(
from = userEmail,
to = "friend@example.com",
subject = "周末聚会",
body = "Hi\n\n周末有空一起吃饭吗?\n\n期待你的回复!"
)
// 添加草稿
repository.saveDraft(
from = userEmail,
to = "colleague@example.com",
subject = "项目进度汇报",
body = "本周项目进度:\n1. 完成了..."
)
Toast.makeText(this@MainActivity, "已添加测试数据", Toast.LENGTH_SHORT).show()
loadEmails()
}
}
override fun onResume() {
super.onResume()
loadEmails()
}
}

@ -0,0 +1,88 @@
package com.example.emailclient.ui
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import com.example.emailclient.data.RegisterRequest
import com.example.emailclient.databinding.ActivityRegisterBinding
import com.example.emailclient.network.RetrofitClient
import kotlinx.coroutines.launch
class RegisterActivity : AppCompatActivity() {
private lateinit var binding: ActivityRegisterBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityRegisterBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.btnRegister.setOnClickListener { register() }
binding.btnBack.setOnClickListener { finish() }
}
private fun register() {
val username = binding.etUsername.text.toString().trim()
val email = binding.etEmail.text.toString().trim()
val password = binding.etPassword.text.toString()
val confirmPassword = binding.etConfirmPassword.text.toString()
if (username.isEmpty()) {
binding.tilUsername.error = "请输入用户名"
return
}
if (email.isEmpty()) {
binding.tilEmail.error = "请输入邮箱"
return
}
if (password.isEmpty()) {
binding.tilPassword.error = "请输入密码"
return
}
if (password.length < 6) {
binding.tilPassword.error = "密码至少6位"
return
}
if (password != confirmPassword) {
binding.tilConfirmPassword.error = "两次密码不一致"
return
}
clearErrors()
setLoading(true)
lifecycleScope.launch {
try {
val response = RetrofitClient.getApiService().register(
RegisterRequest(username, email, password)
)
if (response.isSuccessful && response.body()?.code == 200) {
Toast.makeText(this@RegisterActivity, "注册成功,请登录", Toast.LENGTH_SHORT).show()
finish()
} else {
Toast.makeText(this@RegisterActivity,
response.body()?.message ?: "注册失败", Toast.LENGTH_SHORT).show()
}
} catch (e: Exception) {
Toast.makeText(this@RegisterActivity,
"网络错误: ${e.message}", Toast.LENGTH_SHORT).show()
} finally {
setLoading(false)
}
}
}
private fun clearErrors() {
binding.tilUsername.error = null
binding.tilEmail.error = null
binding.tilPassword.error = null
binding.tilConfirmPassword.error = null
}
private fun setLoading(loading: Boolean) {
binding.progressBar.visibility = if (loading) View.VISIBLE else View.GONE
binding.btnRegister.isEnabled = !loading
}
}

@ -0,0 +1,91 @@
package com.example.emailclient.ui
import android.content.Intent
import android.os.Bundle
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import com.example.emailclient.EmailApp
import com.example.emailclient.databinding.ActivitySettingsBinding
import com.example.emailclient.network.RetrofitClient
import kotlinx.coroutines.launch
class SettingsActivity : AppCompatActivity() {
private lateinit var binding: ActivitySettingsBinding
private val prefs by lazy { EmailApp.instance.preferencesManager }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivitySettingsBinding.inflate(layoutInflater)
setContentView(binding.root)
setSupportActionBar(binding.toolbar)
binding.toolbar.setNavigationOnClickListener { finish() }
loadSettings()
binding.btnSave.setOnClickListener { saveSettings() }
binding.btnLogout.setOnClickListener { logout() }
// 如果未登录,隐藏登出按钮
if (prefs.getTokenSync().isNullOrEmpty()) {
binding.btnLogout.visibility = android.view.View.GONE
}
}
private fun loadSettings() {
val host = prefs.getServerHost()
binding.etServerHost.setText(host)
binding.etSmtpPort.setText(prefs.getSmtpPort().toString())
binding.etPop3Port.setText(prefs.getPop3Port().toString())
binding.etApiPort.setText(prefs.getApiPort().toString())
// 显示提示
binding.tilServerHost.helperText = "模拟器用10.0.2.2真机用电脑IP"
}
private fun saveSettings() {
val host = binding.etServerHost.text.toString().trim()
val smtpPort = binding.etSmtpPort.text.toString().toIntOrNull() ?: 2525
val pop3Port = binding.etPop3Port.text.toString().toIntOrNull() ?: 1100
val apiPort = binding.etApiPort.text.toString().toIntOrNull() ?: 5000
if (host.isEmpty()) {
binding.tilServerHost.error = "请输入服务器地址"
return
}
lifecycleScope.launch {
prefs.saveServerConfig(host, smtpPort, pop3Port, apiPort)
RetrofitClient.resetClient()
Toast.makeText(this@SettingsActivity, "设置已保存", Toast.LENGTH_SHORT).show()
finish()
}
}
private fun logout() {
AlertDialog.Builder(this)
.setTitle("确认退出")
.setMessage("确定要退出登录吗?")
.setPositiveButton("确定") { _, _ ->
lifecycleScope.launch {
val token = prefs.getTokenSync()
if (!token.isNullOrEmpty()) {
try {
RetrofitClient.getApiService().logout("Bearer $token")
} catch (_: Exception) {}
}
prefs.clearUserInfo()
val intent = Intent(this@SettingsActivity, LoginActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
startActivity(intent)
finish()
}
}
.setNegativeButton("取消", null)
.show()
}
}

@ -0,0 +1,34 @@
package com.example.emailclient.ui
import android.annotation.SuppressLint
import android.content.Intent
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import com.example.emailclient.EmailApp
import com.example.emailclient.R
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
@SuppressLint("CustomSplashScreen")
class SplashActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_splash)
lifecycleScope.launch {
delay(1500)
val token = EmailApp.instance.preferencesManager.token.first()
val intent = if (token.isNullOrEmpty()) {
Intent(this@SplashActivity, LoginActivity::class.java)
} else {
Intent(this@SplashActivity, MainActivity::class.java)
}
startActivity(intent)
finish()
}
}
}

@ -0,0 +1,116 @@
package com.example.emailclient.ui
import android.content.Intent
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import com.example.emailclient.EmailApp
import com.example.emailclient.data.local.LocalEmail
import com.example.emailclient.databinding.ActivityStarredBinding
import com.example.emailclient.ui.adapter.LocalEmailAdapter
import kotlinx.coroutines.launch
/**
* 星标邮件页面 - 使用本地存储
*/
class StarredActivity : AppCompatActivity() {
private lateinit var binding: ActivityStarredBinding
private lateinit var adapter: LocalEmailAdapter
private val repository by lazy { EmailApp.instance.emailRepository }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityStarredBinding.inflate(layoutInflater)
setContentView(binding.root)
setSupportActionBar(binding.toolbar)
binding.toolbar.setNavigationOnClickListener { finish() }
setupRecyclerView()
binding.swipeRefresh.setOnRefreshListener { loadStarredEmails() }
loadStarredEmails()
}
private fun setupRecyclerView() {
adapter = LocalEmailAdapter(
onClick = { email -> openEmail(email) },
onLongClick = { email -> showDeleteDialog(email) },
onStarClick = { email -> toggleStar(email) }
)
binding.recyclerView.layoutManager = LinearLayoutManager(this)
binding.recyclerView.adapter = adapter
}
private fun openEmail(email: LocalEmail) {
if (email.isDraft) {
val intent = Intent(this, ComposeActivity::class.java).apply {
putExtra("draft_id", email.id)
putExtra("recipient", email.recipientAddress)
putExtra("subject", email.subject)
putExtra("body", email.body)
}
startActivity(intent)
} else {
val intent = Intent(this, LocalEmailDetailActivity::class.java).apply {
putExtra("email_id", email.id)
}
startActivity(intent)
}
}
private fun showDeleteDialog(email: LocalEmail) {
AlertDialog.Builder(this)
.setTitle("删除邮件")
.setMessage("确定要删除这封邮件吗?")
.setPositiveButton("删除") { _, _ ->
deleteEmail(email)
}
.setNegativeButton("取消", null)
.show()
}
private fun deleteEmail(email: LocalEmail) {
lifecycleScope.launch {
repository.deleteEmail(email.id)
Toast.makeText(this@StarredActivity, "已删除", Toast.LENGTH_SHORT).show()
loadStarredEmails()
}
}
private fun loadStarredEmails() {
setLoading(true)
lifecycleScope.launch {
val emails = repository.getStarredEmails()
binding.swipeRefresh.isRefreshing = false
setLoading(false)
adapter.submitList(emails)
binding.tvEmpty.visibility = if (emails.isEmpty()) View.VISIBLE else View.GONE
}
}
private fun toggleStar(email: LocalEmail) {
lifecycleScope.launch {
repository.toggleStar(email.id)
// 取消星标后从列表移除
loadStarredEmails()
Toast.makeText(this@StarredActivity, "已取消星标", Toast.LENGTH_SHORT).show()
}
}
private fun setLoading(loading: Boolean) {
binding.progressBar.visibility = if (loading) View.VISIBLE else View.GONE
}
override fun onResume() {
super.onResume()
loadStarredEmails()
}
}

@ -0,0 +1,88 @@
package com.example.emailclient.ui
import android.content.Intent
import android.os.Bundle
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import com.example.emailclient.EmailApp
import com.example.emailclient.databinding.ActivityStarredEmailDetailBinding
import com.example.emailclient.network.RetrofitClient
import kotlinx.coroutines.launch
class StarredEmailDetailActivity : AppCompatActivity() {
private lateinit var binding: ActivityStarredEmailDetailBinding
private var emailId: Int = 0
private var isStarred: Boolean = true
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityStarredEmailDetailBinding.inflate(layoutInflater)
setContentView(binding.root)
setSupportActionBar(binding.toolbar)
binding.toolbar.setNavigationOnClickListener { finish() }
emailId = intent.getIntExtra("email_id", 0)
val from = intent.getStringExtra("from") ?: ""
val to = intent.getStringExtra("to") ?: ""
val subject = intent.getStringExtra("subject") ?: "(无主题)"
val body = intent.getStringExtra("body") ?: ""
val date = intent.getStringExtra("date") ?: ""
isStarred = intent.getBooleanExtra("is_starred", true)
binding.tvFrom.text = from.ifEmpty { "未知发件人" }
binding.tvTo.text = to.ifEmpty { "未知收件人" }
binding.tvSubject.text = subject
binding.tvBody.text = body
binding.tvDate.text = date.take(19).replace("T", " ")
updateStarButton()
binding.btnStar.setOnClickListener { toggleStar() }
binding.fabReply.setOnClickListener {
val intent = Intent(this, ComposeActivity::class.java).apply {
putExtra("reply_to", from)
putExtra("subject", subject)
}
startActivity(intent)
}
}
private fun updateStarButton() {
binding.btnStar.text = if (isStarred) "⭐ 已星标" else "☆ 添加星标"
}
private fun toggleStar() {
val token = EmailApp.instance.preferencesManager.getTokenSync() ?: return
lifecycleScope.launch {
try {
val response = RetrofitClient.getApiService().toggleStar("Bearer $token", emailId)
if (response.isSuccessful && response.body()?.code == 200) {
val data = response.body()?.data
if (data != null) {
isStarred = data.isStarred
updateStarButton()
Toast.makeText(
this@StarredEmailDetailActivity,
if (isStarred) "已添加星标" else "已取消星标",
Toast.LENGTH_SHORT
).show()
}
} else if (response.code() == 401 || response.body()?.code == 401) {
Toast.makeText(this@StarredEmailDetailActivity, "登录已过期,请重新登录", Toast.LENGTH_SHORT).show()
EmailApp.instance.preferencesManager.clearUserInfo()
startActivity(Intent(this@StarredEmailDetailActivity, LoginActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
})
finish()
}
} catch (e: Exception) {
Toast.makeText(this@StarredEmailDetailActivity, "操作失败", Toast.LENGTH_SHORT).show()
}
}
}
}

@ -0,0 +1,52 @@
package com.example.emailclient.ui.adapter
import android.graphics.Color
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import com.example.emailclient.R
import com.example.emailclient.data.AdminUser
import com.google.android.material.button.MaterialButton
class AdminUserAdapter(
private val users: MutableList<AdminUser>,
private val onToggle: (AdminUser) -> Unit,
private val onDelete: (AdminUser) -> Unit
) : RecyclerView.Adapter<AdminUserAdapter.ViewHolder>() {
class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
val tvUsername: TextView = view.findViewById(R.id.tvUsername)
val tvEmail: TextView = view.findViewById(R.id.tvEmail)
val tvRole: TextView = view.findViewById(R.id.tvRole)
val tvStatus: TextView = view.findViewById(R.id.tvStatus)
val btnToggle: MaterialButton = view.findViewById(R.id.btnToggle)
val btnDelete: MaterialButton = view.findViewById(R.id.btnDelete)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_admin_user, parent, false)
return ViewHolder(view)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val user = users[position]
holder.tvUsername.text = user.username
holder.tvEmail.text = user.email
holder.tvRole.text = if (user.isAdmin) "管理员" else "普通用户"
holder.tvRole.background.setTint(if (user.isAdmin) Color.parseColor("#FF9800") else Color.parseColor("#4CAF50"))
holder.tvStatus.text = if (user.isActive) "状态: 启用" else "状态: 禁用"
holder.tvStatus.setTextColor(if (user.isActive) Color.parseColor("#4CAF50") else Color.parseColor("#F44336"))
holder.btnToggle.text = if (user.isActive) "禁用" else "启用"
holder.btnToggle.setOnClickListener { onToggle(user) }
holder.btnDelete.setOnClickListener { onDelete(user) }
}
override fun getItemCount() = users.size
}

@ -0,0 +1,48 @@
package com.example.emailclient.ui.adapter
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.example.emailclient.data.Contact
import com.example.emailclient.databinding.ItemContactBinding
class ContactAdapter(
private val onClick: (Contact) -> Unit,
private val onDelete: (Contact) -> Unit
) : ListAdapter<Contact, ContactAdapter.ViewHolder>(DiffCallback()) {
class ViewHolder(
private val binding: ItemContactBinding,
private val onClick: (Contact) -> Unit,
private val onDelete: (Contact) -> Unit
) : RecyclerView.ViewHolder(binding.root) {
fun bind(contact: Contact) {
binding.tvName.text = contact.name
binding.tvEmail.text = contact.email
binding.root.setOnClickListener { onClick(contact) }
binding.btnDelete.setOnClickListener { onDelete(contact) }
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val binding = ItemContactBinding.inflate(
LayoutInflater.from(parent.context), parent, false
)
return ViewHolder(binding, onClick, onDelete)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(getItem(position))
}
class DiffCallback : DiffUtil.ItemCallback<Contact>() {
override fun areItemsTheSame(oldItem: Contact, newItem: Contact) =
oldItem.id == newItem.id
override fun areContentsTheSame(oldItem: Contact, newItem: Contact) =
oldItem == newItem
}
}

@ -0,0 +1,60 @@
package com.example.emailclient.ui.adapter
import android.graphics.Color
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.example.emailclient.R
import com.example.emailclient.data.ContactEmail
import com.example.emailclient.databinding.ItemContactEmailBinding
class ContactEmailAdapter(
private val onClick: (ContactEmail) -> Unit
) : ListAdapter<ContactEmail, ContactEmailAdapter.ViewHolder>(DiffCallback()) {
class ViewHolder(
private val binding: ItemContactEmailBinding,
private val onClick: (ContactEmail) -> Unit
) : RecyclerView.ViewHolder(binding.root) {
fun bind(email: ContactEmail) {
// 显示方向标签
if (email.isSent) {
binding.tvDirection.text = "发出"
binding.tvDirection.setBackgroundColor(Color.parseColor("#4CAF50"))
} else {
binding.tvDirection.text = "收到"
binding.tvDirection.setBackgroundColor(Color.parseColor("#2196F3"))
}
binding.tvSubject.text = email.subject
binding.tvPreview.text = email.body.take(100).replace("\n", " ")
// 格式化日期
val date = email.createdAt.substringBefore("T")
binding.tvDate.text = date
binding.root.setOnClickListener { onClick(email) }
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val binding = ItemContactEmailBinding.inflate(
LayoutInflater.from(parent.context), parent, false
)
return ViewHolder(binding, onClick)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(getItem(position))
}
class DiffCallback : DiffUtil.ItemCallback<ContactEmail>() {
override fun areItemsTheSame(oldItem: ContactEmail, newItem: ContactEmail) =
oldItem.id == newItem.id
override fun areContentsTheSame(oldItem: ContactEmail, newItem: ContactEmail) =
oldItem == newItem
}
}

@ -0,0 +1,55 @@
package com.example.emailclient.ui.adapter
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.example.emailclient.databinding.ItemEmailBinding
import com.example.emailclient.network.Pop3Email
import java.text.SimpleDateFormat
import java.util.*
class EmailAdapter(
private val onClick: (Pop3Email) -> Unit,
private val onDelete: (Pop3Email) -> Unit
) : ListAdapter<Pop3Email, EmailAdapter.ViewHolder>(DiffCallback()) {
class ViewHolder(
private val binding: ItemEmailBinding,
private val onClick: (Pop3Email) -> Unit,
private val onDelete: (Pop3Email) -> Unit
) : RecyclerView.ViewHolder(binding.root) {
fun bind(email: Pop3Email) {
binding.tvSender.text = email.from.ifEmpty { "未知发件人" }
binding.tvSubject.text = email.subject
binding.tvPreview.text = email.body.take(100).replace("\n", " ")
binding.tvDate.text = SimpleDateFormat("MM/dd", Locale.getDefault()).format(Date())
binding.root.setOnClickListener { onClick(email) }
binding.root.setOnLongClickListener {
onDelete(email)
true
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val binding = ItemEmailBinding.inflate(
LayoutInflater.from(parent.context), parent, false
)
return ViewHolder(binding, onClick, onDelete)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(getItem(position))
}
class DiffCallback : DiffUtil.ItemCallback<Pop3Email>() {
override fun areItemsTheSame(oldItem: Pop3Email, newItem: Pop3Email) =
oldItem.uid == newItem.uid
override fun areContentsTheSame(oldItem: Pop3Email, newItem: Pop3Email) =
oldItem == newItem
}
}

@ -0,0 +1,46 @@
package com.example.emailclient.ui.adapter
import android.graphics.Color
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageButton
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import com.example.emailclient.R
import com.example.emailclient.data.EmailFilter
class FilterAdapter(
private val filters: MutableList<EmailFilter>,
private val onDelete: (EmailFilter) -> Unit
) : RecyclerView.Adapter<FilterAdapter.ViewHolder>() {
class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
val tvType: TextView = view.findViewById(R.id.tvType)
val tvValue: TextView = view.findViewById(R.id.tvValue)
val tvAction: TextView = view.findViewById(R.id.tvAction)
val btnDelete: ImageButton = view.findViewById(R.id.btnDelete)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_filter, parent, false)
return ViewHolder(view)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val filter = filters[position]
holder.tvType.text = if (filter.filterType == "email") "邮箱" else "IP"
holder.tvType.background.setTint(if (filter.filterType == "email") Color.parseColor("#4CAF50") else Color.parseColor("#FF9800"))
holder.tvValue.text = filter.value
holder.tvAction.text = if (filter.action == "block") "阻止" else "允许"
holder.tvAction.background.setTint(if (filter.action == "block") Color.parseColor("#F44336") else Color.parseColor("#4CAF50"))
holder.btnDelete.setOnClickListener { onDelete(filter) }
}
override fun getItemCount() = filters.size
}

@ -0,0 +1,93 @@
package com.example.emailclient.ui.adapter
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.example.emailclient.R
import com.example.emailclient.data.local.LocalEmail
import com.example.emailclient.databinding.ItemLocalEmailBinding
import java.text.SimpleDateFormat
import java.util.*
class LocalEmailAdapter(
private val onClick: (LocalEmail) -> Unit,
private val onLongClick: (LocalEmail) -> Unit,
private val onStarClick: ((LocalEmail) -> Unit)? = null
) : ListAdapter<LocalEmail, LocalEmailAdapter.ViewHolder>(DiffCallback()) {
class ViewHolder(
private val binding: ItemLocalEmailBinding,
private val onClick: (LocalEmail) -> Unit,
private val onLongClick: (LocalEmail) -> Unit,
private val onStarClick: ((LocalEmail) -> Unit)?
) : RecyclerView.ViewHolder(binding.root) {
private val dateFormat = SimpleDateFormat("MM/dd HH:mm", Locale.getDefault())
fun bind(email: LocalEmail) {
// 根据是发送还是接收显示不同的地址
binding.tvSender.text = if (email.isSent || email.isDraft) {
"收件人: ${email.recipientAddress.ifEmpty { "未填写" }}"
} else {
email.senderAddress.ifEmpty { "未知发件人" }
}
binding.tvSubject.text = email.subject.ifEmpty { "(无主题)" }
binding.tvPreview.text = email.body.take(100).replace("\n", " ")
binding.tvDate.text = dateFormat.format(Date(email.createdAt))
// 未读标记
if (!email.isRead && !email.isSent && !email.isDraft) {
binding.tvSubject.setTextColor(binding.root.context.getColor(R.color.primary))
} else {
binding.tvSubject.setTextColor(binding.root.context.getColor(R.color.black))
}
// 星标按钮
if (onStarClick != null) {
binding.ivStar.visibility = View.VISIBLE
binding.ivStar.setImageResource(
if (email.isStarred) android.R.drawable.btn_star_big_on
else android.R.drawable.btn_star_big_off
)
binding.ivStar.setOnClickListener { onStarClick.invoke(email) }
} else {
binding.ivStar.visibility = View.GONE
}
// 草稿标记
if (email.isDraft) {
binding.tvDraftTag.visibility = View.VISIBLE
} else {
binding.tvDraftTag.visibility = View.GONE
}
binding.root.setOnClickListener { onClick(email) }
binding.root.setOnLongClickListener {
onLongClick(email)
true
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val binding = ItemLocalEmailBinding.inflate(
LayoutInflater.from(parent.context), parent, false
)
return ViewHolder(binding, onClick, onLongClick, onStarClick)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(getItem(position))
}
class DiffCallback : DiffUtil.ItemCallback<LocalEmail>() {
override fun areItemsTheSame(oldItem: LocalEmail, newItem: LocalEmail) =
oldItem.id == newItem.id
override fun areContentsTheSame(oldItem: LocalEmail, newItem: LocalEmail) =
oldItem == newItem
}
}

@ -0,0 +1,54 @@
package com.example.emailclient.ui.adapter
import android.graphics.Color
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import com.example.emailclient.R
import com.example.emailclient.data.LogEntry
class LogAdapter(
private val logs: MutableList<LogEntry>
) : RecyclerView.Adapter<LogAdapter.ViewHolder>() {
class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
val tvLevel: TextView = view.findViewById(R.id.tvLevel)
val tvSource: TextView = view.findViewById(R.id.tvSource)
val tvTime: TextView = view.findViewById(R.id.tvTime)
val tvMessage: TextView = view.findViewById(R.id.tvMessage)
val tvIp: TextView = view.findViewById(R.id.tvIp)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_log, parent, false)
return ViewHolder(view)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val log = logs[position]
holder.tvLevel.text = log.level
holder.tvLevel.background.setTint(when (log.level) {
"ERROR" -> Color.parseColor("#F44336")
"WARNING" -> Color.parseColor("#FF9800")
else -> Color.parseColor("#4CAF50")
})
holder.tvSource.text = log.source ?: "-"
holder.tvTime.text = log.createdAt.replace("T", " ").take(19)
holder.tvMessage.text = log.message
holder.tvIp.text = if (log.ipAddress.isNullOrEmpty()) "" else "IP: ${log.ipAddress}"
holder.tvIp.visibility = if (log.ipAddress.isNullOrEmpty()) View.GONE else View.VISIBLE
}
override fun getItemCount() = logs.size
fun addLogs(newLogs: List<LogEntry>) {
val startPos = logs.size
logs.addAll(newLogs)
notifyItemRangeInserted(startPos, newLogs.size)
}
}

@ -0,0 +1,51 @@
package com.example.emailclient.ui.adapter
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.example.emailclient.data.StarredEmail
import com.example.emailclient.databinding.ItemStarredEmailBinding
class StarredEmailAdapter(
private val onClick: (StarredEmail) -> Unit,
private val onStarClick: (StarredEmail) -> Unit
) : ListAdapter<StarredEmail, StarredEmailAdapter.ViewHolder>(DiffCallback()) {
class ViewHolder(
private val binding: ItemStarredEmailBinding,
private val onClick: (StarredEmail) -> Unit,
private val onStarClick: (StarredEmail) -> Unit
) : RecyclerView.ViewHolder(binding.root) {
fun bind(email: StarredEmail) {
binding.tvSender.text = email.senderAddress.ifEmpty { "未知发件人" }
binding.tvSubject.text = email.subject
binding.tvPreview.text = email.body.take(100).replace("\n", " ")
binding.tvDate.text = email.createdAt.take(10)
binding.btnStar.text = if (email.isStarred) "" else ""
binding.root.setOnClickListener { onClick(email) }
binding.btnStar.setOnClickListener { onStarClick(email) }
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val binding = ItemStarredEmailBinding.inflate(
LayoutInflater.from(parent.context), parent, false
)
return ViewHolder(binding, onClick, onStarClick)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(getItem(position))
}
class DiffCallback : DiffUtil.ItemCallback<StarredEmail>() {
override fun areItemsTheSame(oldItem: StarredEmail, newItem: StarredEmail) =
oldItem.id == newItem.id
override fun areContentsTheSame(oldItem: StarredEmail, newItem: StarredEmail) =
oldItem == newItem
}
}

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/purple_500" />
<corners android:radius="4dp" />
</shape>

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="@color/light_gray" />
</shape>

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="@color/white">
<path
android:fillColor="@android:color/white"
android:pathData="M20,11H7.83l5.59,-5.59L12,4l-8,8 8,8 1.41,-1.41L7.83,13H20v-2z"/>
</vector>

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- 邮件图标 -->
<path
android:fillColor="#FFFFFF"
android:pathData="M54,30L24,50L24,78L84,78L84,50L54,30ZM54,36L78,52L78,72L30,72L30,52L54,36ZM54,42L36,54L72,54L54,42Z"/>
</vector>

@ -0,0 +1,285 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="?attr/colorPrimary"
app:titleTextColor="@android:color/white" />
<com.google.android.material.tabs.TabLayout
android:id="@+id/tabLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:tabMode="scrollable"
app:tabGravity="start" />
</com.google.android.material.appbar.AppBarLayout>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<!-- 仪表盘 -->
<LinearLayout
android:id="@+id/dashboardLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="系统概览"
android:textSize="18sp"
android:textStyle="bold"
android:layout_marginBottom="16dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<androidx.cardview.widget.CardView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_margin="4dp"
app:cardCornerRadius="8dp"
app:cardElevation="2dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp"
android:gravity="center">
<TextView
android:id="@+id/tvUserCount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="0"
android:textSize="24sp"
android:textStyle="bold"
android:textColor="@color/purple_500" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/user_count"
android:textColor="@android:color/darker_gray" />
</LinearLayout>
</androidx.cardview.widget.CardView>
<androidx.cardview.widget.CardView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_margin="4dp"
app:cardCornerRadius="8dp"
app:cardElevation="2dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp"
android:gravity="center">
<TextView
android:id="@+id/tvEmailCount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="0"
android:textSize="24sp"
android:textStyle="bold"
android:textColor="@color/teal_700" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/email_count"
android:textColor="@android:color/darker_gray" />
</LinearLayout>
</androidx.cardview.widget.CardView>
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginTop="8dp">
<androidx.cardview.widget.CardView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_margin="4dp"
app:cardCornerRadius="8dp"
app:cardElevation="2dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp"
android:gravity="center">
<TextView
android:id="@+id/tvSmtpPort"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="25"
android:textSize="24sp"
android:textStyle="bold"
android:textColor="#FF5722" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/smtp_port"
android:textColor="@android:color/darker_gray" />
</LinearLayout>
</androidx.cardview.widget.CardView>
<androidx.cardview.widget.CardView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_margin="4dp"
app:cardCornerRadius="8dp"
app:cardElevation="2dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp"
android:gravity="center">
<TextView
android:id="@+id/tvPop3Port"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="110"
android:textSize="24sp"
android:textStyle="bold"
android:textColor="#9C27B0" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/pop3_port"
android:textColor="@android:color/darker_gray" />
</LinearLayout>
</androidx.cardview.widget.CardView>
</LinearLayout>
</LinearLayout>
<!-- 列表 -->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone"
android:padding="8dp" />
<!-- 群发邮件 -->
<LinearLayout
android:id="@+id/broadcastLayout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp"
android:visibility="gone">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="向所有用户发送邮件"
android:textColor="@android:color/darker_gray"
android:layout_marginBottom="16dp" />
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/subject">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etBroadcastSubject"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:layout_marginTop="8dp"
android:hint="@string/content">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etBroadcastBody"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:inputType="textMultiLine"
android:gravity="top" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/btnSendBroadcast"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/send_broadcast" />
</LinearLayout>
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:visibility="gone" />
</FrameLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/btnAdd"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="16dp"
android:text="添加"
android:visibility="gone"
app:icon="@android:drawable/ic_input_add" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnClearLogs"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|start"
android:layout_margin="16dp"
android:text="@string/clear_logs"
android:visibility="gone"
app:backgroundTint="#F44336" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnLoadMore"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="16dp"
android:text="加载更多"
android:visibility="gone" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

@ -0,0 +1,109 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="@color/primary"
app:navigationIcon="@drawable/ic_back"
app:title="@string/compose"
app:titleTextColor="@color/white" />
</com.google.android.material.appbar.AppBarLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilRecipient"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/recipient">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etRecipient"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textEmailAddress" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilSubject"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:hint="@string/subject">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etSubject"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilBody"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="8dp"
android:layout_weight="1"
android:hint="@string/content">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etBody"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="top"
android:inputType="textMultiLine" />
</com.google.android.material.textfield.TextInputLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:orientation="horizontal">
<Button
android:id="@+id/btnSaveDraft"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:layout_weight="1"
android:text="@string/save_draft" />
<Button
android:id="@+id/btnSend"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/send" />
</LinearLayout>
</LinearLayout>
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:visibility="gone" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

@ -0,0 +1,135 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="@color/primary"
app:navigationIcon="@drawable/ic_back"
app:title="联系人详情"
app:titleTextColor="@color/white" />
</com.google.android.material.appbar.AppBarLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<!-- 联系人信息卡片 -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:cardCornerRadius="8dp"
app:cardElevation="2dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilName"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="姓名">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etName"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</com.google.android.material.textfield.TextInputLayout>
<TextView
android:id="@+id/tvEmail"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:textColor="@color/gray"
android:textSize="14sp" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilNote"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:hint="备注">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etNote"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textMultiLine"
android:minLines="2" />
</com.google.android.material.textfield.TextInputLayout>
<Button
android:id="@+id/btnSave"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="保存修改" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- 邮件往来 -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="邮件往来"
android:textColor="@color/primary"
android:textSize="16sp"
android:textStyle="bold" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="8dp"
android:layout_weight="1" />
<TextView
android:id="@+id/tvEmpty"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="暂无邮件往来"
android:textColor="@color/gray"
android:visibility="gone" />
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:visibility="gone" />
</LinearLayout>
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fabCompose"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="16dp"
android:src="@android:drawable/ic_dialog_email"
app:tint="@color/white" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

@ -0,0 +1,60 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="@color/primary"
app:navigationIcon="@drawable/ic_back"
app:title="@string/contacts"
app:titleTextColor="@color/white" />
</com.google.android.material.appbar.AppBarLayout>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<TextView
android:id="@+id/tvEmpty"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="暂无联系人"
android:textColor="@color/gray"
android:visibility="gone" />
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:visibility="gone" />
</FrameLayout>
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fabAdd"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="16dp"
android:src="@android:drawable/ic_input_add"
app:tint="@color/white" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

@ -0,0 +1,139 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="@color/primary"
app:navigationIcon="@drawable/ic_back"
app:title="邮件详情"
app:titleTextColor="@color/white" />
</com.google.android.material.appbar.AppBarLayout>
<androidx.core.widget.NestedScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView
android:id="@+id/tvSubject"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textSize="20sp"
android:textStyle="bold"
android:textColor="@color/black" />
<Button
android:id="@+id/btnStar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="☆ 星标"
android:textSize="14sp"
style="@style/Widget.MaterialComponents.Button.OutlinedButton" />
</LinearLayout>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginVertical="12dp"
android:background="@color/light_gray" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="发件人: "
android:textColor="@color/gray" />
<TextView
android:id="@+id/tvFrom"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/black" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:orientation="horizontal">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="收件人: "
android:textColor="@color/gray" />
<TextView
android:id="@+id/tvTo"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/black" />
</LinearLayout>
<TextView
android:id="@+id/tvDate"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textColor="@color/gray"
android:textSize="12sp" />
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginVertical="12dp"
android:background="@color/light_gray" />
<TextView
android:id="@+id/tvBody"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="@color/black"
android:textSize="16sp"
android:lineSpacingMultiplier="1.3" />
</LinearLayout>
</androidx.core.widget.NestedScrollView>
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fabReply"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="16dp"
android:src="@android:drawable/ic_menu_send"
app:tint="@color/white" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

@ -0,0 +1,162 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="@color/primary"
app:navigationIcon="@drawable/ic_back"
app:title="邮件详情"
app:titleTextColor="@color/white" />
</com.google.android.material.appbar.AppBarLayout>
<androidx.core.widget.NestedScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<!-- 主题 -->
<TextView
android:id="@+id/tvSubject"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="@color/black"
android:textSize="20sp"
android:textStyle="bold" />
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginVertical="12dp"
android:background="@color/light_gray" />
<!-- 发件人/收件人信息 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<ImageView
android:layout_width="40dp"
android:layout_height="40dp"
android:background="@drawable/circle_bg"
android:padding="8dp"
android:src="@android:drawable/ic_menu_myplaces"
android:contentDescription="头像" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/tvFrom"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="@color/black"
android:textSize="15sp" />
<TextView
android:id="@+id/tvTo"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textColor="@color/gray"
android:textSize="13sp" />
<TextView
android:id="@+id/tvDate"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textColor="@color/gray"
android:textSize="12sp" />
</LinearLayout>
<ImageButton
android:id="@+id/btnStar"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="@android:drawable/btn_star_big_off"
android:contentDescription="星标" />
</LinearLayout>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginVertical="12dp"
android:background="@color/light_gray" />
<!-- 邮件正文 -->
<TextView
android:id="@+id/tvBody"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:lineSpacingExtra="4dp"
android:textColor="@color/black"
android:textSize="15sp" />
</LinearLayout>
</androidx.core.widget.NestedScrollView>
<!-- 底部操作栏 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:background="@color/white"
android:elevation="8dp"
android:orientation="horizontal"
android:padding="8dp">
<Button
android:id="@+id/btnReply"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="4dp"
android:layout_weight="1"
android:text="回复" />
<Button
android:id="@+id/btnDelete"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:layout_weight="1"
android:text="删除"
android:textColor="@color/accent" />
</LinearLayout>
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:visibility="gone" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

@ -0,0 +1,94 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="24dp">
<TextView
android:id="@+id/tvTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/login"
android:textSize="32sp"
android:textStyle="bold"
android:textColor="@color/primary"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
android:layout_marginTop="60dp" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilUsername"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="40dp"
android:hint="@string/username"
app:layout_constraintTop_toBottomOf="@id/tvTitle">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etUsername"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilPassword"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:hint="@string/password"
app:endIconMode="password_toggle"
app:layout_constraintTop_toBottomOf="@id/tilUsername">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etPassword"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textPassword" />
</com.google.android.material.textfield.TextInputLayout>
<Button
android:id="@+id/btnLogin"
android:layout_width="match_parent"
android:layout_height="56dp"
android:layout_marginTop="32dp"
android:text="@string/login"
app:layout_constraintTop_toBottomOf="@id/tilPassword" />
<Button
android:id="@+id/btnRegister"
style="@style/Widget.MaterialComponents.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/register"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/btnLogin" />
<Button
android:id="@+id/btnSettings"
style="@style/Widget.MaterialComponents.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="服务器设置"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/btnRegister" />
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

@ -0,0 +1,76 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="@color/primary"
app:title="@string/inbox"
app:titleTextColor="@color/white" />
<com.google.android.material.tabs.TabLayout
android:id="@+id/tabLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/primary"
app:tabIndicatorColor="@color/white"
app:tabSelectedTextColor="@color/white"
app:tabTextColor="@color/white" />
</com.google.android.material.appbar.AppBarLayout>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipeRefresh"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<TextView
android:id="@+id/tvEmpty"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="@string/no_emails"
android:textColor="@color/gray"
android:textSize="16sp"
android:visibility="gone" />
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:visibility="gone" />
</FrameLayout>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fabCompose"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="16dp"
android:src="@android:drawable/ic_input_add"
app:tint="@color/white" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

@ -0,0 +1,117 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="24dp">
<TextView
android:id="@+id/tvTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/register"
android:textSize="32sp"
android:textStyle="bold"
android:textColor="@color/primary"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
android:layout_marginTop="60dp" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilUsername"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="40dp"
android:hint="@string/username"
app:layout_constraintTop_toBottomOf="@id/tvTitle">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etUsername"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilEmail"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:hint="@string/email"
app:layout_constraintTop_toBottomOf="@id/tilUsername">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etEmail"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textEmailAddress" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilPassword"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:hint="@string/password"
app:endIconMode="password_toggle"
app:layout_constraintTop_toBottomOf="@id/tilEmail">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etPassword"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textPassword" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilConfirmPassword"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:hint="@string/confirm_password"
app:endIconMode="password_toggle"
app:layout_constraintTop_toBottomOf="@id/tilPassword">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etConfirmPassword"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textPassword" />
</com.google.android.material.textfield.TextInputLayout>
<Button
android:id="@+id/btnRegister"
android:layout_width="match_parent"
android:layout_height="56dp"
android:layout_marginTop="32dp"
android:text="@string/register"
app:layout_constraintTop_toBottomOf="@id/tilConfirmPassword" />
<Button
android:id="@+id/btnBack"
style="@style/Widget.MaterialComponents.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="返回登录"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/btnRegister" />
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

@ -0,0 +1,127 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="@color/primary"
app:navigationIcon="@drawable/ic_back"
app:title="@string/settings"
app:titleTextColor="@color/white" />
</com.google.android.material.appbar.AppBarLayout>
<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="服务器配置"
android:textSize="18sp"
android:textStyle="bold"
android:textColor="@color/primary" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilServerHost"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:hint="@string/server_host">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etServerHost"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilSmtpPort"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:hint="@string/smtp_port">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etSmtpPort"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="number" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilPop3Port"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:hint="@string/pop3_port">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etPop3Port"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="number" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilApiPort"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:hint="@string/api_port">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etApiPort"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="number" />
</com.google.android.material.textfield.TextInputLayout>
<Button
android:id="@+id/btnSave"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="@string/save" />
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginVertical="24dp"
android:background="@color/light_gray" />
<Button
android:id="@+id/btnLogout"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/logout"
android:textColor="@color/accent" />
</LinearLayout>
</ScrollView>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/primary">
<ImageView
android:id="@+id/ivLogo"
android:layout_width="120dp"
android:layout_height="120dp"
android:src="@android:drawable/ic_dialog_email"
android:tint="@color/white"
app:layout_constraintBottom_toTopOf="@id/tvAppName"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed" />
<TextView
android:id="@+id/tvAppName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/app_name"
android:textColor="@color/white"
android:textSize="28sp"
android:textStyle="bold"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/ivLogo" />
</androidx.constraintlayout.widget.ConstraintLayout>

@ -0,0 +1,59 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="@color/primary"
app:navigationIcon="@drawable/ic_back"
app:title="⭐ 星标邮件"
app:titleTextColor="@color/white" />
</com.google.android.material.appbar.AppBarLayout>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipeRefresh"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<TextView
android:id="@+id/tvEmpty"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="暂无星标邮件"
android:textColor="@color/gray"
android:textSize="16sp"
android:visibility="gone" />
</FrameLayout>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:visibility="gone" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

@ -0,0 +1,139 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="@color/primary"
app:navigationIcon="@drawable/ic_back"
app:title="邮件详情"
app:titleTextColor="@color/white" />
</com.google.android.material.appbar.AppBarLayout>
<androidx.core.widget.NestedScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView
android:id="@+id/tvSubject"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textSize="20sp"
android:textStyle="bold"
android:textColor="@color/black" />
<Button
android:id="@+id/btnStar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="⭐ 已星标"
android:textSize="14sp"
style="@style/Widget.MaterialComponents.Button.OutlinedButton" />
</LinearLayout>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginVertical="12dp"
android:background="@color/light_gray" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="发件人: "
android:textColor="@color/gray" />
<TextView
android:id="@+id/tvFrom"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/black" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:orientation="horizontal">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="收件人: "
android:textColor="@color/gray" />
<TextView
android:id="@+id/tvTo"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/black" />
</LinearLayout>
<TextView
android:id="@+id/tvDate"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textColor="@color/gray"
android:textSize="12sp" />
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginVertical="12dp"
android:background="@color/light_gray" />
<TextView
android:id="@+id/tvBody"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="@color/black"
android:textSize="16sp"
android:lineSpacingMultiplier="1.3" />
</LinearLayout>
</androidx.core.widget.NestedScrollView>
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fabReply"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="16dp"
android:src="@android:drawable/ic_menu_send"
app:tint="@color/white" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

@ -0,0 +1,50 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/name">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:hint="@string/email">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etEmail"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textEmailAddress" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:hint="@string/note">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etNote"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text" />
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout>

@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/filter_type"
android:textColor="@android:color/darker_gray" />
<Spinner
android:id="@+id/spinnerType"
android:layout_width="match_parent"
android:layout_height="48dp"
android:layout_marginTop="4dp" />
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:hint="@string/filter_value">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etValue"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text" />
</com.google.android.material.textfield.TextInputLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/filter_action"
android:textColor="@android:color/darker_gray" />
<Spinner
android:id="@+id/spinnerAction"
android:layout_width="match_parent"
android:layout_height="48dp"
android:layout_marginTop="4dp" />
</LinearLayout>

@ -0,0 +1,53 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/username">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etUsername"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:hint="@string/email">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etEmail"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textEmailAddress" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:hint="@string/password">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etPassword"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textPassword" />
</com.google.android.material.textfield.TextInputLayout>
<CheckBox
android:id="@+id/cbIsAdmin"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/is_admin" />
</LinearLayout>

@ -0,0 +1,78 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="4dp"
app:cardCornerRadius="8dp"
app:cardElevation="2dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="12dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:id="@+id/tvUsername"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tvRole"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/badge_background"
android:paddingHorizontal="8dp"
android:paddingVertical="2dp"
android:textColor="@android:color/white"
android:textSize="12sp" />
</LinearLayout>
<TextView
android:id="@+id/tvEmail"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textColor="@android:color/darker_gray"
android:textSize="14sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="horizontal">
<TextView
android:id="@+id/tvStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textSize="12sp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnToggle"
style="@style/Widget.MaterialComponents.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="36dp"
android:textSize="12sp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnDelete"
style="@style/Widget.MaterialComponents.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="36dp"
android:text="@string/delete"
android:textColor="#F44336"
android:textSize="12sp" />
</LinearLayout>
</LinearLayout>
</androidx.cardview.widget.CardView>

@ -0,0 +1,60 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="8dp"
android:layout_marginVertical="4dp"
app:cardCornerRadius="8dp"
app:cardElevation="2dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="12dp"
android:gravity="center_vertical">
<ImageView
android:layout_width="40dp"
android:layout_height="40dp"
android:src="@android:drawable/ic_menu_myplaces"
android:tint="@color/primary" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/tvName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/black"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tvEmail"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textColor="@color/gray"
android:textSize="14sp" />
</LinearLayout>
<ImageButton
android:id="@+id/btnDelete"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="@android:drawable/ic_menu_delete"
android:tint="@color/gray" />
</LinearLayout>
</androidx.cardview.widget.CardView>

@ -0,0 +1,67 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginVertical="4dp"
app:cardCornerRadius="8dp"
app:cardElevation="1dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="12dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:id="@+id/tvDirection"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@color/primary"
android:paddingHorizontal="8dp"
android:paddingVertical="2dp"
android:textColor="@color/white"
android:textSize="12sp" />
<TextView
android:id="@+id/tvDate"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:gravity="end"
android:textColor="@color/gray"
android:textSize="12sp" />
</LinearLayout>
<TextView
android:id="@+id/tvSubject"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:ellipsize="end"
android:maxLines="1"
android:textColor="@color/black"
android:textSize="14sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tvPreview"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:ellipsize="end"
android:maxLines="2"
android:textColor="@color/gray"
android:textSize="13sp" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>

@ -0,0 +1,65 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="8dp"
android:layout_marginVertical="4dp"
app:cardCornerRadius="8dp"
app:cardElevation="2dp">
<LinearLayout
android:id="@+id/container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="12dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:id="@+id/tvSender"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:ellipsize="end"
android:maxLines="1"
android:textColor="@color/black"
android:textSize="16sp" />
<TextView
android:id="@+id/tvDate"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/gray"
android:textSize="12sp" />
</LinearLayout>
<TextView
android:id="@+id/tvSubject"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:ellipsize="end"
android:maxLines="1"
android:textColor="@color/black"
android:textSize="14sp" />
<TextView
android:id="@+id/tvPreview"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:ellipsize="end"
android:maxLines="2"
android:textColor="@color/gray"
android:textSize="13sp" />
</LinearLayout>
</androidx.cardview.widget.CardView>

@ -0,0 +1,66 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="4dp"
app:cardCornerRadius="8dp"
app:cardElevation="2dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="12dp"
android:gravity="center_vertical">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:id="@+id/tvType"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/badge_background"
android:paddingHorizontal="8dp"
android:paddingVertical="2dp"
android:textColor="@android:color/white"
android:textSize="12sp" />
<TextView
android:id="@+id/tvAction"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:background="@drawable/badge_background"
android:paddingHorizontal="8dp"
android:paddingVertical="2dp"
android:textColor="@android:color/white"
android:textSize="12sp" />
</LinearLayout>
<TextView
android:id="@+id/tvValue"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textSize="14sp" />
</LinearLayout>
<ImageButton
android:id="@+id/btnDelete"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="@android:drawable/ic_menu_delete"
android:contentDescription="@string/delete" />
</LinearLayout>
</androidx.cardview.widget.CardView>

@ -0,0 +1,97 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="8dp"
android:layout_marginVertical="4dp"
app:cardCornerRadius="8dp"
app:cardElevation="2dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="12dp">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:id="@+id/tvDraftTag"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@color/orange"
android:paddingHorizontal="6dp"
android:paddingVertical="2dp"
android:text="草稿"
android:textColor="@color/white"
android:textSize="10sp"
android:layout_marginEnd="8dp"
android:visibility="gone" />
<TextView
android:id="@+id/tvSender"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:ellipsize="end"
android:maxLines="1"
android:textColor="@color/black"
android:textSize="15sp" />
<TextView
android:id="@+id/tvDate"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/gray"
android:textSize="12sp" />
</LinearLayout>
<TextView
android:id="@+id/tvSubject"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:ellipsize="end"
android:maxLines="1"
android:textColor="@color/black"
android:textSize="14sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tvPreview"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:ellipsize="end"
android:maxLines="2"
android:textColor="@color/gray"
android:textSize="13sp" />
</LinearLayout>
<ImageView
android:id="@+id/ivStar"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_gravity="center_vertical"
android:layout_marginStart="8dp"
android:contentDescription="星标"
android:padding="4dp"
android:src="@android:drawable/btn_star_big_off"
android:visibility="gone" />
</LinearLayout>
</androidx.cardview.widget.CardView>

@ -0,0 +1,64 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="4dp"
app:cardCornerRadius="8dp"
app:cardElevation="2dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="12dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:id="@+id/tvLevel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/badge_background"
android:paddingHorizontal="8dp"
android:paddingVertical="2dp"
android:textColor="@android:color/white"
android:textSize="12sp" />
<TextView
android:id="@+id/tvSource"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:textColor="@android:color/darker_gray"
android:textSize="12sp" />
<TextView
android:id="@+id/tvTime"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="end"
android:textColor="@android:color/darker_gray"
android:textSize="12sp" />
</LinearLayout>
<TextView
android:id="@+id/tvMessage"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textSize="14sp" />
<TextView
android:id="@+id/tvIp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textColor="@android:color/darker_gray"
android:textSize="12sp" />
</LinearLayout>
</androidx.cardview.widget.CardView>

@ -0,0 +1,85 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="8dp"
android:layout_marginVertical="4dp"
app:cardCornerRadius="8dp"
app:cardElevation="2dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="12dp"
android:gravity="center_vertical">
<Button
android:id="@+id/btnStar"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:text="⭐"
android:textSize="18sp"
android:minWidth="0dp"
android:minHeight="0dp"
android:padding="0dp" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:layout_marginStart="8dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:id="@+id/tvSender"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:ellipsize="end"
android:maxLines="1"
android:textColor="@color/black"
android:textSize="16sp" />
<TextView
android:id="@+id/tvDate"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/gray"
android:textSize="12sp" />
</LinearLayout>
<TextView
android:id="@+id/tvSubject"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:ellipsize="end"
android:maxLines="1"
android:textColor="@color/black"
android:textSize="14sp" />
<TextView
android:id="@+id/tvPreview"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:ellipsize="end"
android:maxLines="2"
android:textColor="@color/gray"
android:textSize="13sp" />
</LinearLayout>
</LinearLayout>
</androidx.cardview.widget.CardView>

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_starred"
android:icon="@android:drawable/btn_star_big_on"
android:title="@string/starred"
app:showAsAction="ifRoom" />
<item
android:id="@+id/action_contacts"
android:icon="@android:drawable/ic_menu_myplaces"
android:title="@string/contacts"
app:showAsAction="ifRoom" />
<item
android:id="@+id/action_admin"
android:icon="@android:drawable/ic_menu_manage"
android:title="@string/admin"
android:visible="false"
app:showAsAction="ifRoom" />
<item
android:id="@+id/action_settings"
android:icon="@android:drawable/ic_menu_preferences"
android:title="@string/settings"
app:showAsAction="ifRoom" />
<item
android:id="@+id/action_test_data"
android:title="添加测试数据"
app:showAsAction="never" />
</menu>

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/primary"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/primary"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="72dp"
android:height="72dp"
android:viewportWidth="72"
android:viewportHeight="72">
<path
android:fillColor="#1976D2"
android:pathData="M0,0h72v72h-72z"/>
<path
android:fillColor="#FFFFFF"
android:pathData="M36,18L12,36v24h48V36L36,18zM36,24l18,13.5v16.5H18V37.5L36,24z"/>
</vector>

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="72dp"
android:height="72dp"
android:viewportWidth="72"
android:viewportHeight="72">
<path
android:fillColor="#1976D2"
android:pathData="M36,36m-36,0a36,36 0,1 1,72 0a36,36 0,1 1,-72 0"/>
<path
android:fillColor="#FFFFFF"
android:pathData="M36,21L15,36v21h42V36L36,21zM36,27l15,10.5v13.5H21V37.5L36,27z"/>
</vector>

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="48"
android:viewportHeight="48">
<path
android:fillColor="#1976D2"
android:pathData="M0,0h48v48h-48z"/>
<path
android:fillColor="#FFFFFF"
android:pathData="M24,12L8,24v16h32V24L24,12zM24,16l12,9v11H12V25l12,-9z"/>
</vector>

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="48"
android:viewportHeight="48">
<path
android:fillColor="#1976D2"
android:pathData="M24,24m-24,0a24,24 0,1 1,48 0a24,24 0,1 1,-48 0"/>
<path
android:fillColor="#FFFFFF"
android:pathData="M24,14L10,24v14h28V24L24,14zM24,18l10,7v9H14V25l10,-7z"/>
</vector>

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save