diff --git a/app/build.gradle.kts b/app/build.gradle.kts index fd9f9ff..3b5706c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -8,7 +8,6 @@ plugins { alias(libs.plugins.kotlin.compose) } -// 自动版本号管理 val versionPropsFile = file("version.properties") fun getVersionCode(): Int { val props = Properties() @@ -45,7 +44,6 @@ android { testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - // 作者信息 buildConfigField("String", "AUTHOR", "\"无闻风\"") buildConfigField("String", "BUILD_DATE", "\"${SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(Date())}\"") } @@ -90,13 +88,30 @@ dependencies { implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.activity.compose) implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.material.icons.extended) implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui.graphics) implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.navigation.compose) + implementation(libs.androidx.datastore.preferences) + + implementation(libs.retrofit) + implementation(libs.retrofit.converter.gson) + implementation(libs.okhttp) + implementation(libs.okhttp.logging.interceptor) + implementation(libs.gson) + + implementation(libs.coil.compose) + + implementation(libs.camerax.camera2) + implementation(libs.camerax.lifecycle) + implementation(libs.camerax.view) + implementation(libs.mlkit.barcode.scanning) + + implementation(libs.kotlinx.coroutines.android) - // 打印SDK implementation(files("libs/lcprintsdk-release.aar")) testImplementation(libs.junit) @@ -106,4 +121,4 @@ dependencies { androidTestImplementation(libs.androidx.junit) debugImplementation(libs.androidx.compose.ui.test.manifest) debugImplementation(libs.androidx.compose.ui.tooling) -} \ No newline at end of file +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a88b7ea..589f329 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,7 +2,19 @@ + + + + + + + + + + + + android:theme="@style/Theme.Ops_android" + android:usesCleartextTraffic="true" + tools:targetApi="31"> - - \ No newline at end of file + diff --git a/app/src/main/java/com/example/ops_android/AppContainer.kt b/app/src/main/java/com/example/ops_android/AppContainer.kt new file mode 100644 index 0000000..2a1416f --- /dev/null +++ b/app/src/main/java/com/example/ops_android/AppContainer.kt @@ -0,0 +1,37 @@ +package com.example.ops_android + +import android.content.Context +import com.example.ops_android.data.cache.UserCache +import com.example.ops_android.data.local.SessionManager +import com.example.ops_android.data.remote.ApiServiceFactory +import com.example.ops_android.data.remote.api.* +import com.example.ops_android.data.repository.* +import com.example.ops_android.printer.PrinterManager +import kotlinx.coroutines.runBlocking + +class AppContainer(context: Context) { + val sessionManager = SessionManager(context) + private val apiServiceFactory = ApiServiceFactory(sessionManager) + + val userApi by lazy { apiServiceFactory.create() } + val usersApi by lazy { apiServiceFactory.create() } + val purchaseApi by lazy { apiServiceFactory.create() } + val workOrderApi by lazy { apiServiceFactory.create() } + val warehouseApi by lazy { apiServiceFactory.create() } + val customerApi by lazy { apiServiceFactory.create() } + val scheduleApi by lazy { apiServiceFactory.create() } + + val authRepository by lazy { AuthRepository(userApi, sessionManager) } + val orderRepository by lazy { OrderRepository(purchaseApi) } + val workOrderRepository by lazy { WorkOrderRepository(workOrderApi) } + val warehouseRepository by lazy { WarehouseRepository(warehouseApi) } + val customerRepository by lazy { CustomerRepository(customerApi) } + val scheduleRepository by lazy { ScheduleRepository(scheduleApi) } + val userCache by lazy { UserCache(usersApi) } + + var printerManager: PrinterManager? = null + + init { + runBlocking { sessionManager.init() } + } +} diff --git a/app/src/main/java/com/example/ops_android/MainActivity.kt b/app/src/main/java/com/example/ops_android/MainActivity.kt index c0fe4df..3703b28 100644 --- a/app/src/main/java/com/example/ops_android/MainActivity.kt +++ b/app/src/main/java/com/example/ops_android/MainActivity.kt @@ -4,32 +4,33 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.* +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Surface import androidx.compose.runtime.* -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp import com.example.ops_android.printer.PrinterManager +import com.example.ops_android.ui.LocalAppContainer +import com.example.ops_android.ui.navigation.OPSNavGraph import com.example.ops_android.ui.theme.Ops_androidTheme +import kotlinx.coroutines.runBlocking class MainActivity : ComponentActivity() { - private lateinit var printerManager: PrinterManager override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - printerManager = PrinterManager(this) + + val appContainer = (application as OpsApplication).appContainer + val printerManager = PrinterManager(this) + appContainer.printerManager = printerManager + val isLoggedIn = runBlocking { appContainer.sessionManager.isLoggedIn() } enableEdgeToEdge() setContent { Ops_androidTheme { - Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> - PrinterTestScreen( - printerManager = printerManager, - modifier = Modifier.padding(innerPadding) - ) + CompositionLocalProvider(LocalAppContainer provides appContainer) { + Surface(modifier = Modifier.fillMaxSize()) { + OPSNavGraph(isLoggedIn = isLoggedIn) + } } } } @@ -37,412 +38,11 @@ class MainActivity : ComponentActivity() { override fun onStart() { super.onStart() - printerManager.bindService() + (application as OpsApplication).appContainer.printerManager?.bindService() } override fun onDestroy() { super.onDestroy() - printerManager.unbindService() + (application as OpsApplication).appContainer.printerManager?.unbindService() } } - -@Composable -fun PrinterTestScreen( - printerManager: PrinterManager, - modifier: Modifier = Modifier -) { - var statusMessage by remember { mutableStateOf("未连接打印服务") } - var isConnected by remember { mutableStateOf(false) } - - DisposableEffect(printerManager) { - printerManager.setConnectionCallback { connected, message -> - isConnected = connected - statusMessage = message - } - onDispose { } - } - - Column( - modifier = modifier - .fillMaxSize() - .padding(16.dp) - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - Text( - text = "PDA 打印机测试", - style = MaterialTheme.typography.headlineMedium, - modifier = Modifier.padding(bottom = 8.dp) - ) - - Text( - text = "v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE}) - ${BuildConfig.AUTHOR}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(bottom = 8.dp) - ) - - // ── 状态卡片 ────────────────────────────────────── - Card( - colors = CardDefaults.cardColors( - containerColor = if (isConnected) - MaterialTheme.colorScheme.primaryContainer - else - MaterialTheme.colorScheme.errorContainer - ) - ) { - Text( - text = statusMessage, - modifier = Modifier.padding(16.dp), - style = MaterialTheme.typography.bodyMedium - ) - } - - HorizontalDivider() - - // ══════════════════════════════════════════════════ - // 连接控制 - // ══════════════════════════════════════════════════ - Text("连接控制", style = MaterialTheme.typography.titleMedium) - - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Button( - onClick = { printerManager.bindService() }, - modifier = Modifier.weight(1f) - ) { Text("绑定服务") } - - Button( - onClick = { - printerManager.initDevice().fold( - onSuccess = { statusMessage = "打印机初始化成功" }, - onFailure = { statusMessage = "初始化失败: ${it.message}" } - ) - }, - modifier = Modifier.weight(1f) - ) { Text("初始化") } - } - - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Button( - onClick = { - printerManager.closeDevice().fold( - onSuccess = { statusMessage = "打印机已关闭" }, - onFailure = { statusMessage = "关闭失败: ${it.message}" } - ) - }, - modifier = Modifier.weight(1f) - ) { Text("关闭打印机") } - - Button( - onClick = { - printerManager.unbindService() - isConnected = false - statusMessage = "服务已解绑" - }, - modifier = Modifier.weight(1f) - ) { Text("解绑服务") } - } - - HorizontalDivider() - - // ══════════════════════════════════════════════════ - // 快速打印 - // ══════════════════════════════════════════════════ - Text("快速打印", style = MaterialTheme.typography.titleMedium) - - Button( - onClick = { - printerManager.quickText("Hello Printer!\n测试打印\n").fold( - onSuccess = { statusMessage = "快速打印成功" }, - onFailure = { statusMessage = "打印失败: ${it.message}" } - ) - }, - modifier = Modifier.fillMaxWidth() - ) { Text("快速打印测试") } - - Button( - onClick = { - printerManager.buildJob() - .text("工单号: WO-001\n操作员: 张三\n") - .build() - .fold( - onSuccess = { statusMessage = "PrintJob 打印成功" }, - onFailure = { statusMessage = "失败: ${it.message}" } - ) - }, - modifier = Modifier.fillMaxWidth() - ) { Text("PrintJob 构建器测试") } - - HorizontalDivider() - - // ══════════════════════════════════════════════════ - // 单步打印(手动组合) - // ══════════════════════════════════════════════════ - Text("单步打印", style = MaterialTheme.typography.titleMedium) - - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Button( - onClick = { - printerManager.ensureOpen().fold( - onSuccess = { - printerManager.addText("手动 text\n").fold( - onSuccess = { statusMessage = "文字已加入缓冲区" }, - onFailure = { statusMessage = "失败: ${it.message}" } - ) - }, - onFailure = { statusMessage = "失败: ${it.message}" } - ) - }, - modifier = Modifier.weight(1f) - ) { Text("添加文字") } - - Button( - onClick = { - printerManager.ensureOpen().fold( - onSuccess = { - printerManager.addQR("https://example.com").fold( - onSuccess = { statusMessage = "QR 已加入缓冲区" }, - onFailure = { statusMessage = "失败: ${it.message}" } - ) - }, - onFailure = { statusMessage = "失败: ${it.message}" } - ) - }, - modifier = Modifier.weight(1f) - ) { Text("添加QR") } - } - - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Button( - onClick = { - printerManager.ensureOpen().fold( - onSuccess = { - printerManager.addBarcode("1234567890").fold( - onSuccess = { statusMessage = "条码已加入缓冲区" }, - onFailure = { statusMessage = "失败: ${it.message}" } - ) - }, - onFailure = { statusMessage = "失败: ${it.message}" } - ) - }, - modifier = Modifier.weight(1f) - ) { Text("添加条码") } - - Button( - onClick = { - printerManager.ensureOpen().fold( - onSuccess = { - printerManager.addLineFeed(3).fold( - onSuccess = { statusMessage = "空行已加入缓冲区" }, - onFailure = { statusMessage = "失败: ${it.message}" } - ) - }, - onFailure = { statusMessage = "失败: ${it.message}" } - ) - }, - modifier = Modifier.weight(1f) - ) { Text("添加空行") } - } - - Button( - onClick = { - printerManager.ensureOpen().fold( - onSuccess = { - printerManager.commit().fold( - onSuccess = { statusMessage = "已提交打印" }, - onFailure = { statusMessage = "失败: ${it.message}" } - ) - }, - onFailure = { statusMessage = "失败: ${it.message}" } - ) - }, - modifier = Modifier.fillMaxWidth() - ) { Text("提交打印 (start)") } - - HorizontalDivider() - - // ══════════════════════════════════════════════════ - // 样式设置 - // ══════════════════════════════════════════════════ - Text("样式设置", style = MaterialTheme.typography.titleMedium) - - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Button( - onClick = { - printerManager.ensureOpen().fold( - onSuccess = { statusMessage = "设备已打开,可设置样式" }, - onFailure = { statusMessage = "失败: ${it.message}" } - ) - printerManager.setFontSize(32).fold( - onSuccess = { statusMessage = "大字体已设置" }, - onFailure = { statusMessage = "失败: ${it.message}" } - ) - }, - modifier = Modifier.weight(1f) - ) { Text("大字体") } - - Button( - onClick = { - printerManager.ensureOpen().fold( - onSuccess = { statusMessage = "设备已打开" }, - onFailure = { statusMessage = "失败: ${it.message}" } - ) - printerManager.setBold(true).fold( - onSuccess = { statusMessage = "粗体已设置" }, - onFailure = { statusMessage = "失败: ${it.message}" } - ) - }, - modifier = Modifier.weight(1f) - ) { Text("粗体") } - } - - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Button( - onClick = { - printerManager.setUnderline(true).fold( - onSuccess = { statusMessage = "下划线已设置" }, - onFailure = { statusMessage = "失败: ${it.message}" } - ) - }, - modifier = Modifier.weight(1f) - ) { Text("下划线") } - - Button( - onClick = { - printerManager.setDensity(5).fold( - onSuccess = { statusMessage = "浓度已设置" }, - onFailure = { statusMessage = "失败: ${it.message}" } - ) - }, - modifier = Modifier.weight(1f) - ) { Text("浓度") } - } - - HorizontalDivider() - - // ══════════════════════════════════════════════════ - // 标签打印 - // ══════════════════════════════════════════════════ - Text("标签打印(黑标对齐)", style = MaterialTheme.typography.titleMedium) - - Button( - onClick = { - printerManager.quickLabel("标签打印测试\n工单号: WO-001\n").fold( - onSuccess = { statusMessage = "标签打印成功" }, - onFailure = { statusMessage = "标签打印失败: ${it.message}" } - ) - }, - modifier = Modifier.fillMaxWidth() - ) { Text("快速标签打印") } - - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Button( - onClick = { - printerManager.enableMarkDetection(true).fold( - onSuccess = { statusMessage = "已开启黑标检测" }, - onFailure = { statusMessage = "失败: ${it.message}" } - ) - }, - modifier = Modifier.weight(1f) - ) { Text("开启黑标") } - - Button( - onClick = { - printerManager.enableMarkDetection(false).fold( - onSuccess = { statusMessage = "已关闭黑标检测" }, - onFailure = { statusMessage = "失败: ${it.message}" } - ) - }, - modifier = Modifier.weight(1f) - ) { Text("关闭黑标") } - } - - Button( - onClick = { - printerManager.ensureOpen().fold( - onSuccess = { - printerManager.enableMarkDetection(true).fold( - onSuccess = { - printerManager.gotoNextMark().fold( - onSuccess = { statusMessage = "已移动到下一标签" }, - onFailure = { statusMessage = "失败: ${it.message}" } - ) - }, - onFailure = { statusMessage = "失败: ${it.message}" } - ) - }, - onFailure = { statusMessage = "失败: ${it.message}" } - ) - }, - modifier = Modifier.fillMaxWidth() - ) { Text("下一标签") } - - HorizontalDivider() - - // ══════════════════════════════════════════════════ - // 综合测试 - // ══════════════════════════════════════════════════ - Text("综合测试", style = MaterialTheme.typography.titleMedium) - - Button( - onClick = { - runComprehensiveTest(printerManager) { msg -> statusMessage = msg } - }, - modifier = Modifier.fillMaxWidth(), - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.secondary - ) - ) { Text("运行完整测试") } - } -} - -private fun runComprehensiveTest( - printerManager: PrinterManager, - onStatus: (String) -> Unit -) { - onStatus("开始综合测试...") - - printerManager.initDevice().onFailure { - onStatus("初始化失败: ${it.message}") - return - } - - printerManager.setFontSize(32) - printerManager.setBold(true) - printerManager.addText("=== 打印测试 ===\n") - - printerManager.setFontSize(24) - printerManager.setBold(false) - printerManager.addText("普通文本测试\n") - - printerManager.setBold(true) - printerManager.addText("粗体文本测试\n") - printerManager.setBold(false) - - printerManager.setUnderline(true) - printerManager.addText("下划线文本测试\n") - printerManager.setUnderline(false) - - printerManager.addLineFeed(1) - - printerManager.addText("二维码测试:\n") - printerManager.addQR("https://example.com") - printerManager.addLineFeed(1) - - printerManager.addText("条形码测试:\n") - printerManager.addBarcode("123456789012") - printerManager.addLineFeed(3) - - printerManager.addText("--------------------------------\n") - printerManager.addText("测试完成\n") - printerManager.addLineFeed(2) - - printerManager.commit().fold( - onSuccess = { - onStatus("综合测试完成,开始打印") - Thread.sleep(1500) - printerManager.closeDevice() - }, - onFailure = { onStatus("提交打印失败: ${it.message}") } - ) -} diff --git a/app/src/main/java/com/example/ops_android/OpsApplication.kt b/app/src/main/java/com/example/ops_android/OpsApplication.kt new file mode 100644 index 0000000..c18b430 --- /dev/null +++ b/app/src/main/java/com/example/ops_android/OpsApplication.kt @@ -0,0 +1,13 @@ +package com.example.ops_android + +import android.app.Application + +class OpsApplication : Application() { + lateinit var appContainer: AppContainer + private set + + override fun onCreate() { + super.onCreate() + appContainer = AppContainer(this) + } +} diff --git a/app/src/main/java/com/example/ops_android/data/cache/UserCache.kt b/app/src/main/java/com/example/ops_android/data/cache/UserCache.kt new file mode 100644 index 0000000..69485f6 --- /dev/null +++ b/app/src/main/java/com/example/ops_android/data/cache/UserCache.kt @@ -0,0 +1,52 @@ +package com.example.ops_android.data.cache + +import com.example.ops_android.data.model.UserDetail +import com.example.ops_android.data.remote.api.UsersApi +import kotlinx.coroutines.CompletableDeferred +import java.util.concurrent.ConcurrentHashMap + +class UserCache( + private val usersApi: UsersApi +) { + private val cache = ConcurrentHashMap() + private val inflightRequests = ConcurrentHashMap>() + + suspend fun fetchUserInfo(userID: Int): UserDetail? { + if (userID == 0) return null + + cache[userID]?.let { return it } + + inflightRequests[userID]?.let { return it.await() } + + val deferred = CompletableDeferred() + inflightRequests[userID] = deferred + + try { + val response = usersApi.getUserInfoFromUserID(userID) + val info = if (response.errCode == 0 && response.data?.userinfo != null) { + response.data.userinfo.also { cache[userID] = it } + } else { + null + } + deferred.complete(info) + return info + } catch (e: Exception) { + deferred.complete(null) + return null + } finally { + inflightRequests.remove(userID) + } + } + + fun getUsername(userID: Int): String { + if (userID == 0) return "" + val user = cache[userID] + return user?.Username ?: "用户$userID" + } + + fun getCachedUser(userID: Int): UserDetail? = cache[userID] + + fun clear() { + cache.clear() + } +} diff --git a/app/src/main/java/com/example/ops_android/data/local/SessionManager.kt b/app/src/main/java/com/example/ops_android/data/local/SessionManager.kt new file mode 100644 index 0000000..1b99031 --- /dev/null +++ b/app/src/main/java/com/example/ops_android/data/local/SessionManager.kt @@ -0,0 +1,95 @@ +package com.example.ops_android.data.local + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import com.example.ops_android.data.model.Cookie +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +private val Context.dataStore: DataStore by preferencesDataStore(name = "ops_settings") + +class SessionManager(private val context: Context) { + + companion object { + private val KEY_API_BASE_URL = stringPreferencesKey("api_base_url") + private val KEY_COOKIE_VALUE = stringPreferencesKey("cookie_value") + private val KEY_COOKIE_NAME = stringPreferencesKey("cookie_name") + private val KEY_COOKIE_EXPIRES_AT = stringPreferencesKey("cookie_expires_at") + private val KEY_COOKIE_REMEMBER = booleanPreferencesKey("cookie_remember") + } + + @Volatile + var cachedBaseUrl: String = "" + private set + + @Volatile + var cachedCookieValue: String = "" + private set + + fun clearCookieCache() { + cachedCookieValue = "" + } + + val apiBaseUrl: Flow = context.dataStore.data.map { prefs -> prefs[KEY_API_BASE_URL] ?: "" } + val cookieValue: Flow = context.dataStore.data.map { prefs -> prefs[KEY_COOKIE_VALUE] ?: "" } + + suspend fun init() { + cachedBaseUrl = context.dataStore.data.first()[KEY_API_BASE_URL] ?: "" + val value = context.dataStore.data.first()[KEY_COOKIE_VALUE] + cachedCookieValue = value ?: "" + } + + suspend fun getApiBaseUrl(): String = cachedBaseUrl.ifEmpty { + context.dataStore.data.first()[KEY_API_BASE_URL]?.also { cachedBaseUrl = it } ?: "" + } + + suspend fun setApiBaseUrl(url: String) { + cachedBaseUrl = url + context.dataStore.edit { it[KEY_API_BASE_URL] = url } + } + + suspend fun getCookieValue(): String = cachedCookieValue + + suspend fun saveCookie(cookie: Cookie) { + cachedCookieValue = cookie.value ?: "" + context.dataStore.edit { + it[KEY_COOKIE_VALUE] = cookie.value ?: "" + it[KEY_COOKIE_NAME] = cookie.name ?: "" + it[KEY_COOKIE_EXPIRES_AT] = cookie.expiresAt ?: "" + it[KEY_COOKIE_REMEMBER] = cookie.remember + } + } + + suspend fun clearCookie() { + cachedCookieValue = "" + context.dataStore.edit { + it.remove(KEY_COOKIE_VALUE) + it.remove(KEY_COOKIE_NAME) + it.remove(KEY_COOKIE_EXPIRES_AT) + it.remove(KEY_COOKIE_REMEMBER) + } + } + + suspend fun isCookieExpired(): Boolean { + val expiresAt = context.dataStore.data.first()[KEY_COOKIE_EXPIRES_AT] ?: return true + return try { + SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US).parse(expiresAt)?.before(Date()) ?: true + } catch (e: Exception) { true } + } + + suspend fun isLoggedIn(): Boolean { + if (cachedCookieValue.isEmpty()) return false + return !isCookieExpired() + } + + suspend fun logout() = clearCookie() +} diff --git a/app/src/main/java/com/example/ops_android/data/model/Container.kt b/app/src/main/java/com/example/ops_android/data/model/Container.kt new file mode 100644 index 0000000..7f9761d --- /dev/null +++ b/app/src/main/java/com/example/ops_android/data/model/Container.kt @@ -0,0 +1,11 @@ +package com.example.ops_android.data.model + +data class Container( + val id: Int = 0, + val name: String? = null, + val parent_id: Int = 0, + val barcode: String? = null, + val remark: String? = null, + val created_at: String? = null, + val user_id: Int? = null +) diff --git a/app/src/main/java/com/example/ops_android/data/model/Cookie.kt b/app/src/main/java/com/example/ops_android/data/model/Cookie.kt new file mode 100644 index 0000000..b9dc035 --- /dev/null +++ b/app/src/main/java/com/example/ops_android/data/model/Cookie.kt @@ -0,0 +1,10 @@ +package com.example.ops_android.data.model + +import com.google.gson.annotations.SerializedName + +data class Cookie( + @SerializedName("Name") val name: String?, + @SerializedName("Value") val value: String?, + @SerializedName("ExpiresAt") val expiresAt: String?, + @SerializedName("Remember") val remember: Boolean = false +) diff --git a/app/src/main/java/com/example/ops_android/data/model/Customer.kt b/app/src/main/java/com/example/ops_android/data/model/Customer.kt new file mode 100644 index 0000000..ff426ee --- /dev/null +++ b/app/src/main/java/com/example/ops_android/data/model/Customer.kt @@ -0,0 +1,12 @@ +package com.example.ops_android.data.model + +data class Customer( + val id: Int = 0, + val name: String? = null, + val phone: String? = null, + val email: String? = null, + val address: String? = null, + val remark: String? = null, + val created_at: String? = null, + val user_id: Int? = null +) diff --git a/app/src/main/java/com/example/ops_android/data/model/Item.kt b/app/src/main/java/com/example/ops_android/data/model/Item.kt new file mode 100644 index 0000000..7b173e2 --- /dev/null +++ b/app/src/main/java/com/example/ops_android/data/model/Item.kt @@ -0,0 +1,22 @@ +package com.example.ops_android.data.model + +data class Item( + val id: Int = 0, + val name: String? = null, + val serial_number: String? = null, + val spec: String? = null, + val container_id: Int = 0, + val barcode: String? = null, + val remark: String? = null, + val photos: List? = null, + val move_history: List? = null, + val created_at: String? = null, + val user_id: Int? = null +) + +data class MoveHistory( + val from_container: String? = null, + val to_container: String? = null, + val moved_at: String? = null, + val user_id: Int? = null +) diff --git a/app/src/main/java/com/example/ops_android/data/model/OrderCounts.kt b/app/src/main/java/com/example/ops_android/data/model/OrderCounts.kt new file mode 100644 index 0000000..460d0cc --- /dev/null +++ b/app/src/main/java/com/example/ops_android/data/model/OrderCounts.kt @@ -0,0 +1,11 @@ +package com.example.ops_android.data.model + +data class OrderCounts( + val pending: Int = 0, + val ordered: Int = 0, + val arrived: Int = 0, + val received: Int = 0, + val lost: Int = 0, + val returned: Int = 0, + val total: Int = 0 +) diff --git a/app/src/main/java/com/example/ops_android/data/model/PurchaseOrder.kt b/app/src/main/java/com/example/ops_android/data/model/PurchaseOrder.kt new file mode 100644 index 0000000..21c9f96 --- /dev/null +++ b/app/src/main/java/com/example/ops_android/data/model/PurchaseOrder.kt @@ -0,0 +1,31 @@ +package com.example.ops_android.data.model + +data class PurchaseOrder( + val id: Int = 0, + val title: String? = null, + val status: String? = null, + val remark: String? = null, + val link: String? = null, + val style_tags: List? = null, + val costs: List? = null, + val photos: List? = null, + val work_order_ids: List? = null, + val status_logs: List? = null, + val created_at: String? = null, + val updated_at: String? = null, + val user_id: Int? = null +) + +data class Cost( + val amount: Double = 0.0, + val currency: String = "CNY", + val remark: String? = null +) + +data class StatusLog( + val status: String? = null, + val comment: String? = null, + val photos: List? = null, + val created_at: String? = null, + val user_id: Int? = null +) diff --git a/app/src/main/java/com/example/ops_android/data/model/ScheduleEvent.kt b/app/src/main/java/com/example/ops_android/data/model/ScheduleEvent.kt new file mode 100644 index 0000000..f95d38d --- /dev/null +++ b/app/src/main/java/com/example/ops_android/data/model/ScheduleEvent.kt @@ -0,0 +1,12 @@ +package com.example.ops_android.data.model + +data class ScheduleEvent( + val id: Int = 0, + val title: String? = null, + val description: String? = null, + val start_time: String? = null, + val end_time: String? = null, + val color: String? = null, + val created_at: String? = null, + val user_id: Int? = null +) diff --git a/app/src/main/java/com/example/ops_android/data/model/Stats.kt b/app/src/main/java/com/example/ops_android/data/model/Stats.kt new file mode 100644 index 0000000..036bc4f --- /dev/null +++ b/app/src/main/java/com/example/ops_android/data/model/Stats.kt @@ -0,0 +1,6 @@ +package com.example.ops_android.data.model + +data class Stats( + val total_containers: Int = 0, + val total_items: Int = 0 +) diff --git a/app/src/main/java/com/example/ops_android/data/model/UserDetail.kt b/app/src/main/java/com/example/ops_android/data/model/UserDetail.kt new file mode 100644 index 0000000..a3757d6 --- /dev/null +++ b/app/src/main/java/com/example/ops_android/data/model/UserDetail.kt @@ -0,0 +1,8 @@ +package com.example.ops_android.data.model + +data class UserDetail( + val Username: String? = null, + val FirstName: String? = null, + val AvatarPath: String? = null, + val Role: String? = null +) diff --git a/app/src/main/java/com/example/ops_android/data/model/UserInfo.kt b/app/src/main/java/com/example/ops_android/data/model/UserInfo.kt new file mode 100644 index 0000000..b493bce --- /dev/null +++ b/app/src/main/java/com/example/ops_android/data/model/UserInfo.kt @@ -0,0 +1,6 @@ +package com.example.ops_android.data.model + +data class UserInfo( + val ID: Int? = null, + val Name: String? = null +) diff --git a/app/src/main/java/com/example/ops_android/data/model/WorkOrder.kt b/app/src/main/java/com/example/ops_android/data/model/WorkOrder.kt new file mode 100644 index 0000000..a6b1152 --- /dev/null +++ b/app/src/main/java/com/example/ops_android/data/model/WorkOrder.kt @@ -0,0 +1,24 @@ +package com.example.ops_android.data.model + +data class WorkOrder( + val id: Int = 0, + val title: String? = null, + val description: String? = null, + val status: String? = null, + val item_ids: List? = null, + val purchase_order_ids: List? = null, + val customer_id: Int? = null, + val commits: List? = null, + val created_at: String? = null, + val updated_at: String? = null, + val user_id: Int? = null +) + +data class Commit( + val id: Int = 0, + val status: String? = null, + val comment: String? = null, + val photos: List? = null, + val created_at: String? = null, + val user_id: Int? = null +) diff --git a/app/src/main/java/com/example/ops_android/data/model/WorkOrderCounts.kt b/app/src/main/java/com/example/ops_android/data/model/WorkOrderCounts.kt new file mode 100644 index 0000000..e51646c --- /dev/null +++ b/app/src/main/java/com/example/ops_android/data/model/WorkOrderCounts.kt @@ -0,0 +1,11 @@ +package com.example.ops_android.data.model + +data class WorkOrderCounts( + val pending: Int = 0, + val checked: Int = 0, + val parts_ordered: Int = 0, + val repaired: Int = 0, + val returned: Int = 0, + val unrepairable: Int = 0, + val total: Int = 0 +) diff --git a/app/src/main/java/com/example/ops_android/data/remote/ApiResponse.kt b/app/src/main/java/com/example/ops_android/data/remote/ApiResponse.kt new file mode 100644 index 0000000..519f5f5 --- /dev/null +++ b/app/src/main/java/com/example/ops_android/data/remote/ApiResponse.kt @@ -0,0 +1,24 @@ +package com.example.ops_android.data.remote + +import com.google.gson.annotations.SerializedName + +data class ApiResponse( + @SerializedName("return") val data: T? = null +) { + @SerializedName("err_code") + private val _errCode: Any? = null + + val errCode: Int + get() = when (_errCode) { + is Number -> (_errCode as Number).toInt() + is String -> (_errCode as String).toIntOrNull() ?: -1 + else -> -1 + } + + val isSuccess: Boolean get() = errCode == 0 +} + +data class AuthRequest( + @SerializedName("data") val data: Any?, + @SerializedName("userCookieValue") val userCookieValue: String? +) diff --git a/app/src/main/java/com/example/ops_android/data/remote/ApiServiceFactory.kt b/app/src/main/java/com/example/ops_android/data/remote/ApiServiceFactory.kt new file mode 100644 index 0000000..e2eb1a3 --- /dev/null +++ b/app/src/main/java/com/example/ops_android/data/remote/ApiServiceFactory.kt @@ -0,0 +1,44 @@ +package com.example.ops_android.data.remote + +import com.example.ops_android.data.local.SessionManager +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import java.util.concurrent.TimeUnit + +class ApiServiceFactory( + private val sessionManager: SessionManager +) { + val gson: Gson = GsonBuilder() + .setLenient() + .create() + + fun create(serviceClass: Class): T { + return Retrofit.Builder() + .baseUrl("http://localhost/") + .client(okHttpClient) + .addConverterFactory(GsonConverterFactory.create(gson)) + .build() + .create(serviceClass) + } + + inline fun create(): T = create(T::class.java) + + private val okHttpClient: OkHttpClient by lazy { + val loggingInterceptor = HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.BASIC + } + + OkHttpClient.Builder() + .addInterceptor(BaseUrlInterceptor(sessionManager)) + .addInterceptor(CookieInterceptor(sessionManager)) + .addInterceptor(loggingInterceptor) + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(60, TimeUnit.SECONDS) + .build() + } +} diff --git a/app/src/main/java/com/example/ops_android/data/remote/AuthInterceptor.kt b/app/src/main/java/com/example/ops_android/data/remote/AuthInterceptor.kt new file mode 100644 index 0000000..66cf1f2 --- /dev/null +++ b/app/src/main/java/com/example/ops_android/data/remote/AuthInterceptor.kt @@ -0,0 +1,40 @@ +package com.example.ops_android.data.remote + +import com.example.ops_android.data.local.SessionManager +import com.google.gson.JsonParser +import okhttp3.Interceptor +import okhttp3.Response +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.ResponseBody.Companion.toResponseBody + +class AuthInterceptor( + private val sessionManager: SessionManager +) : Interceptor { + + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + val response = chain.proceed(request) + + if (!response.isSuccessful || request.method != "POST") { + return response + } + + val contentType = response.header("Content-Type") ?: "" + if (!contentType.contains("json")) { + return response + } + + val body = response.body ?: return response + val bodyString = body.string() + + try { + val errCode = JsonParser.parseString(bodyString).asJsonObject.get("err_code")?.asInt ?: 0 + if (errCode == -44) { + sessionManager.clearCookieCache() + } + } catch (_: Exception) {} + + val newBody = bodyString.toResponseBody("application/json".toMediaTypeOrNull()) + return response.newBuilder().body(newBody).build() + } +} diff --git a/app/src/main/java/com/example/ops_android/data/remote/BaseUrlInterceptor.kt b/app/src/main/java/com/example/ops_android/data/remote/BaseUrlInterceptor.kt new file mode 100644 index 0000000..a5903a8 --- /dev/null +++ b/app/src/main/java/com/example/ops_android/data/remote/BaseUrlInterceptor.kt @@ -0,0 +1,24 @@ +package com.example.ops_android.data.remote + +import com.example.ops_android.data.local.SessionManager +import com.google.gson.Gson +import okhttp3.Interceptor +import okhttp3.Response +import okhttp3.RequestBody.Companion.toRequestBody + +class BaseUrlInterceptor( + private val sessionManager: SessionManager +) : Interceptor { + + override fun intercept(chain: Interceptor.Chain): Response { + var request = chain.request() + val baseUrl = sessionManager.cachedBaseUrl + + if (baseUrl.isNotEmpty()) { + val newUrl = request.url.toString().replace("http://localhost", baseUrl) + request = request.newBuilder().url(newUrl).build() + } + + return chain.proceed(request) + } +} diff --git a/app/src/main/java/com/example/ops_android/data/remote/CookieInterceptor.kt b/app/src/main/java/com/example/ops_android/data/remote/CookieInterceptor.kt new file mode 100644 index 0000000..92da480 --- /dev/null +++ b/app/src/main/java/com/example/ops_android/data/remote/CookieInterceptor.kt @@ -0,0 +1,48 @@ +package com.example.ops_android.data.remote + +import com.example.ops_android.data.local.SessionManager +import com.google.gson.Gson +import okhttp3.Interceptor +import okhttp3.Response +import okhttp3.RequestBody.Companion.toRequestBody + +class CookieInterceptor( + private val sessionManager: SessionManager +) : Interceptor { + + override fun intercept(chain: Interceptor.Chain): Response { + val originalRequest = chain.request() + + if (originalRequest.method != "POST") { + return chain.proceed(originalRequest) + } + + val cookieValue = sessionManager.cachedCookieValue + + val gson = Gson() + + val oldMap = if (originalRequest.body != null) { + val buffer = okio.Buffer() + originalRequest.body!!.writeTo(buffer) + @Suppress("UNCHECKED_CAST") + gson.fromJson(buffer.readUtf8(), Map::class.java) as? Map ?: emptyMap() + } else { + emptyMap() + } + + val newBody = mapOf( + "data" to (oldMap as Any), + "userCookieValue" to cookieValue + ) + + val newBodyJson = gson.toJson(newBody) + val contentType = originalRequest.body?.contentType() + val newRequestBody = newBodyJson.toRequestBody(contentType) + + return chain.proceed( + originalRequest.newBuilder() + .method(originalRequest.method, newRequestBody) + .build() + ) + } +} diff --git a/app/src/main/java/com/example/ops_android/data/remote/api/CustomerApi.kt b/app/src/main/java/com/example/ops_android/data/remote/api/CustomerApi.kt new file mode 100644 index 0000000..c6e8dfc --- /dev/null +++ b/app/src/main/java/com/example/ops_android/data/remote/api/CustomerApi.kt @@ -0,0 +1,36 @@ +package com.example.ops_android.data.remote.api + +import com.example.ops_android.data.model.Customer +import com.example.ops_android.data.remote.ApiResponse +import retrofit2.http.Body +import retrofit2.http.POST + +data class CustomerListResponse( + val customers: List? +) + +data class CustomerGetRequest( + val id: Int +) + +data class CustomerResponse( + val customer: Customer? +) + +interface CustomerApi { + + @POST("/customer/list") + suspend fun list(@Body request: Map = emptyMap()): ApiResponse + + @POST("/customer/get") + suspend fun get(@Body request: CustomerGetRequest): ApiResponse + + @POST("/customer/add") + suspend fun add(@Body request: Map): ApiResponse + + @POST("/customer/update") + suspend fun update(@Body request: Map): ApiResponse + + @POST("/customer/delete") + suspend fun delete(@Body request: Map): ApiResponse +} diff --git a/app/src/main/java/com/example/ops_android/data/remote/api/PurchaseApi.kt b/app/src/main/java/com/example/ops_android/data/remote/api/PurchaseApi.kt new file mode 100644 index 0000000..cfaf2fb --- /dev/null +++ b/app/src/main/java/com/example/ops_android/data/remote/api/PurchaseApi.kt @@ -0,0 +1,55 @@ +package com.example.ops_android.data.remote.api + +import com.example.ops_android.data.model.OrderCounts +import com.example.ops_android.data.model.PurchaseOrder +import com.example.ops_android.data.remote.ApiResponse +import retrofit2.http.Body +import retrofit2.http.POST + +data class GetOrdersRequest( + val status: String? = null, + val search: String? = null, + val offset: Int = 0, + val limit: Int = 20 +) + +data class OrdersListResponse( + val orders: List?, + val total: Int = 0 +) + +data class GetOrderRequest( + val id: Int +) + +data class UpdateOrderStatusRequest( + val id: Int, + val status: String, + val comment: String? = null, + val photos: List? = null +) + +data class OrderResponse( + val order: PurchaseOrder? +) + +interface PurchaseApi { + + @POST("/purchase/getorders") + suspend fun getOrders(@Body request: GetOrdersRequest): ApiResponse + + @POST("/purchase/getorder") + suspend fun getOrder(@Body request: GetOrderRequest): ApiResponse + + @POST("/purchase/getordercount") + suspend fun getOrderCount(@Body request: Map = emptyMap()): ApiResponse + + @POST("/purchase/updatestatus") + suspend fun updateOrderStatus(@Body request: UpdateOrderStatusRequest): ApiResponse + + @POST("/purchase/addorder") + suspend fun addOrder(@Body request: Map): ApiResponse + + @POST("/purchase/updateorder") + suspend fun updateOrder(@Body request: Map): ApiResponse +} diff --git a/app/src/main/java/com/example/ops_android/data/remote/api/ScheduleApi.kt b/app/src/main/java/com/example/ops_android/data/remote/api/ScheduleApi.kt new file mode 100644 index 0000000..e8539f3 --- /dev/null +++ b/app/src/main/java/com/example/ops_android/data/remote/api/ScheduleApi.kt @@ -0,0 +1,30 @@ +package com.example.ops_android.data.remote.api + +import com.example.ops_android.data.model.ScheduleEvent +import com.example.ops_android.data.remote.ApiResponse +import retrofit2.http.Body +import retrofit2.http.POST + +data class GetEventsRequest( + val start_date: String? = null, + val end_date: String? = null +) + +data class EventsResponse( + val events: List? +) + +interface ScheduleApi { + + @POST("/schedule/getevents") + suspend fun getEvents(@Body request: GetEventsRequest): ApiResponse + + @POST("/schedule/addevent") + suspend fun addEvent(@Body request: Map): ApiResponse + + @POST("/schedule/editevent") + suspend fun editEvent(@Body request: Map): ApiResponse + + @POST("/schedule/deleevent") + suspend fun deleteEvent(@Body request: Map): ApiResponse +} diff --git a/app/src/main/java/com/example/ops_android/data/remote/api/UserApi.kt b/app/src/main/java/com/example/ops_android/data/remote/api/UserApi.kt new file mode 100644 index 0000000..0a530e9 --- /dev/null +++ b/app/src/main/java/com/example/ops_android/data/remote/api/UserApi.kt @@ -0,0 +1,65 @@ +package com.example.ops_android.data.remote.api + +import com.example.ops_android.data.model.Cookie +import com.example.ops_android.data.model.UserDetail +import com.example.ops_android.data.model.UserInfo +import com.example.ops_android.data.remote.ApiResponse +import retrofit2.http.Body +import retrofit2.http.POST + +data class LoginRequest( + val username: String, + val password: String, + val remember: Boolean = true +) + +data class LoginResponse( + val cookie: Cookie? +) + +data class RegisterRequest( + val username: String, + val useremail: String, + val userpass: String +) + +data class ChangePasswordRequest( + val oldpass: String, + val newpass: String +) + +data class ChangeEmailRequest( + val newemail: String +) + +data class UpdateInfoRequest( + val username: String? = null, + val remark: String? = null, + val birthday: String? = null +) + +data class UserInfoResponse( + val user: UserInfo?, + val userInfo: UserDetail? +) + +interface UserApi { + + @POST("/users/login") + suspend fun login(@Body request: LoginRequest): ApiResponse + + @POST("/users/register") + suspend fun register(@Body request: RegisterRequest): ApiResponse + + @POST("/users/getinfo") + suspend fun getUserInfo(@Body request: Map = emptyMap()): ApiResponse + + @POST("/users/changePassword") + suspend fun changePassword(@Body request: ChangePasswordRequest): ApiResponse + + @POST("/users/changeEmail") + suspend fun changeEmail(@Body request: ChangeEmailRequest): ApiResponse + + @POST("/users/updateInfo") + suspend fun updateInfo(@Body request: UpdateInfoRequest): ApiResponse +} diff --git a/app/src/main/java/com/example/ops_android/data/remote/api/UsersApi.kt b/app/src/main/java/com/example/ops_android/data/remote/api/UsersApi.kt new file mode 100644 index 0000000..0ddd589 --- /dev/null +++ b/app/src/main/java/com/example/ops_android/data/remote/api/UsersApi.kt @@ -0,0 +1,16 @@ +package com.example.ops_android.data.remote.api + +import com.example.ops_android.data.model.UserDetail +import com.example.ops_android.data.remote.ApiResponse +import retrofit2.http.GET +import retrofit2.http.Path + +data class UserInfoByIdResponse( + val userinfo: UserDetail? +) + +interface UsersApi { + + @GET("/users/getuserinfo/{userID}") + suspend fun getUserInfoFromUserID(@Path("userID") userID: Int): ApiResponse +} diff --git a/app/src/main/java/com/example/ops_android/data/remote/api/WarehouseApi.kt b/app/src/main/java/com/example/ops_android/data/remote/api/WarehouseApi.kt new file mode 100644 index 0000000..bcfa942 --- /dev/null +++ b/app/src/main/java/com/example/ops_android/data/remote/api/WarehouseApi.kt @@ -0,0 +1,84 @@ +package com.example.ops_android.data.remote.api + +import com.example.ops_android.data.model.Container +import com.example.ops_android.data.model.Item +import com.example.ops_android.data.model.Stats +import com.example.ops_android.data.remote.ApiResponse +import retrofit2.http.Body +import retrofit2.http.POST + +data class ListContainerRequest( + val parent_id: Int = 0 +) + +data class ListContainerResponse( + val containers: List? +) + +data class GetContainerRequest( + val id: Int +) + +data class ContainerResponse( + val container: Container? +) + +data class ListItemRequest( + val container_id: Int = 0 +) + +data class ListItemResponse( + val items: List? +) + +data class GetItemRequest( + val id: Int +) + +data class ItemResponse( + val item: Item? +) + +data class MoveItemRequest( + val item_id: Int, + val new_container: Int +) + +interface WarehouseApi { + + @POST("/warehouse/list_container") + suspend fun listContainer(@Body request: ListContainerRequest): ApiResponse + + @POST("/warehouse/get_container") + suspend fun getContainer(@Body request: GetContainerRequest): ApiResponse + + @POST("/warehouse/add_container") + suspend fun addContainer(@Body request: Map): ApiResponse + + @POST("/warehouse/update_container") + suspend fun updateContainer(@Body request: Map): ApiResponse + + @POST("/warehouse/delete_container") + suspend fun deleteContainer(@Body request: Map): ApiResponse + + @POST("/warehouse/list_item") + suspend fun listItem(@Body request: ListItemRequest): ApiResponse + + @POST("/warehouse/get_item") + suspend fun getItem(@Body request: GetItemRequest): ApiResponse + + @POST("/warehouse/add_item") + suspend fun addItem(@Body request: Map): ApiResponse + + @POST("/warehouse/update_item") + suspend fun updateItem(@Body request: Map): ApiResponse + + @POST("/warehouse/delete_item") + suspend fun deleteItem(@Body request: Map): ApiResponse + + @POST("/warehouse/move_item") + suspend fun moveItem(@Body request: MoveItemRequest): ApiResponse + + @POST("/warehouse/get_stats") + suspend fun getStats(@Body request: Map = emptyMap()): ApiResponse +} diff --git a/app/src/main/java/com/example/ops_android/data/remote/api/WorkOrderApi.kt b/app/src/main/java/com/example/ops_android/data/remote/api/WorkOrderApi.kt new file mode 100644 index 0000000..90f5047 --- /dev/null +++ b/app/src/main/java/com/example/ops_android/data/remote/api/WorkOrderApi.kt @@ -0,0 +1,71 @@ +package com.example.ops_android.data.remote.api + +import com.example.ops_android.data.model.PurchaseOrder +import com.example.ops_android.data.model.WorkOrder +import com.example.ops_android.data.model.WorkOrderCounts +import com.example.ops_android.data.remote.ApiResponse +import retrofit2.http.Body +import retrofit2.http.POST + +data class WorkOrderListRequest( + val status: String? = null, + val search: String? = null, + val offset: Int = 0, + val limit: Int = 20 +) + +data class WorkOrderListResponse( + val work_orders: List?, + val total: Int = 0 +) + +data class WorkOrderGetRequest( + val id: Int +) + +data class WorkOrderResponse( + val work_order: WorkOrder? +) + +data class WorkOrderCommitRequest( + val work_order_id: Int, + val status: String? = null, + val comment: String? = null, + val photos: List? = null +) + +data class SearchPurchaseOrdersRequest( + val search: String, + val limit: Int = 10 +) + +data class SearchPurchaseOrdersResponse( + val purchase_orders: List? +) + +interface WorkOrderApi { + + @POST("/work_order/list") + suspend fun list(@Body request: WorkOrderListRequest): ApiResponse + + @POST("/work_order/get") + suspend fun get(@Body request: WorkOrderGetRequest): ApiResponse + + @POST("/work_order/count") + suspend fun getCount(@Body request: Map = emptyMap()): ApiResponse + + @POST("/work_order/add") + suspend fun add(@Body request: Map): ApiResponse + + @POST("/work_order/update") + suspend fun update(@Body request: Map): ApiResponse + + @POST("/work_order/delete") + suspend fun delete(@Body request: Map): ApiResponse + + @POST("/work_order/commit") + suspend fun commit(@Body request: WorkOrderCommitRequest): ApiResponse + + @POST("/work_order/search_purchase_orders") + suspend fun searchPurchaseOrders(@Body request: SearchPurchaseOrdersRequest): ApiResponse +} diff --git a/app/src/main/java/com/example/ops_android/data/repository/AuthRepository.kt b/app/src/main/java/com/example/ops_android/data/repository/AuthRepository.kt new file mode 100644 index 0000000..4bd84b3 --- /dev/null +++ b/app/src/main/java/com/example/ops_android/data/repository/AuthRepository.kt @@ -0,0 +1,91 @@ +package com.example.ops_android.data.repository + +import com.example.ops_android.data.local.SessionManager +import com.example.ops_android.data.model.Cookie +import com.example.ops_android.data.model.UserDetail +import com.example.ops_android.data.remote.api.* + +class AuthRepository( + private val userApi: UserApi, + private val sessionManager: SessionManager +) { + suspend fun login(username: String, password: String, remember: Boolean = true): Result { + return try { + val response = userApi.login(LoginRequest(username, password, remember)) + if (response.errCode == 0 && response.data?.cookie != null) { + val cookie = response.data.cookie + sessionManager.saveCookie(cookie) + Result.success(cookie) + } else { + Result.failure(Exception("登录失败 (err=${response.errCode}, cookie=${response.data?.cookie})")) + } + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun register(username: String, email: String, password: String): Result { + return try { + val response = userApi.register(RegisterRequest(username, email, password)) + if (response.errCode == 0) Result.success(Unit) + else Result.failure(Exception("注册失败")) + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun getUserInfo(): Result> { + return try { + val response = userApi.getUserInfo() + if (response.errCode == 0 && response.data != null) { + Result.success(Pair(response.data.user, response.data.userInfo)) + } else { + Result.failure(Exception("获取用户信息失败")) + } + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun changePassword(oldPass: String, newPass: String): Result { + return try { + val response = userApi.changePassword(ChangePasswordRequest(oldPass, newPass)) + if (response.errCode == 0) Result.success(Unit) + else Result.failure(Exception("修改密码失败")) + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun changeEmail(newEmail: String): Result { + return try { + val response = userApi.changeEmail(ChangeEmailRequest(newEmail)) + if (response.errCode == 0) Result.success(Unit) + else Result.failure(Exception("修改邮箱失败")) + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun updateInfo(data: UpdateInfoRequest): Result { + return try { + val response = userApi.updateInfo(data) + if (response.errCode == 0) Result.success(Unit) + else Result.failure(Exception("更新信息失败")) + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun logout() { + sessionManager.logout() + } + + suspend fun isLoggedIn(): Boolean = sessionManager.isLoggedIn() + + suspend fun restoreSession(): Boolean { + if (!sessionManager.isLoggedIn()) return false + val result = getUserInfo() + return result.isSuccess + } +} diff --git a/app/src/main/java/com/example/ops_android/data/repository/CustomerRepository.kt b/app/src/main/java/com/example/ops_android/data/repository/CustomerRepository.kt new file mode 100644 index 0000000..c6036f5 --- /dev/null +++ b/app/src/main/java/com/example/ops_android/data/repository/CustomerRepository.kt @@ -0,0 +1,65 @@ +package com.example.ops_android.data.repository + +import com.example.ops_android.data.model.Customer +import com.example.ops_android.data.remote.api.CustomerApi +import com.example.ops_android.data.remote.api.CustomerGetRequest + +class CustomerRepository( + private val customerApi: CustomerApi +) { + suspend fun list(): Result> { + return try { + val response = customerApi.list() + if (response.errCode == 0 && response.data != null) { + Result.success(response.data.customers ?: emptyList()) + } else { + Result.failure(Exception("获取客户列表失败")) + } + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun get(id: Int): Result { + return try { + val response = customerApi.get(CustomerGetRequest(id)) + if (response.errCode == 0 && response.data?.customer != null) { + Result.success(response.data.customer) + } else { + Result.failure(Exception("获取客户详情失败")) + } + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun add(data: Map): Result { + return try { + val response = customerApi.add(data) + if (response.errCode == 0) Result.success(Unit) + else Result.failure(Exception("创建客户失败")) + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun update(data: Map): Result { + return try { + val response = customerApi.update(data) + if (response.errCode == 0) Result.success(Unit) + else Result.failure(Exception("更新客户失败")) + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun delete(id: Int): Result { + return try { + val response = customerApi.delete(mapOf("id" to id)) + if (response.errCode == 0) Result.success(Unit) + else Result.failure(Exception("删除客户失败")) + } catch (e: Exception) { + Result.failure(e) + } + } +} diff --git a/app/src/main/java/com/example/ops_android/data/repository/OrderRepository.kt b/app/src/main/java/com/example/ops_android/data/repository/OrderRepository.kt new file mode 100644 index 0000000..36cb052 --- /dev/null +++ b/app/src/main/java/com/example/ops_android/data/repository/OrderRepository.kt @@ -0,0 +1,81 @@ +package com.example.ops_android.data.repository + +import com.example.ops_android.data.remote.api.PurchaseApi +import com.example.ops_android.data.remote.api.GetOrdersRequest +import com.example.ops_android.data.remote.api.GetOrderRequest +import com.example.ops_android.data.remote.api.UpdateOrderStatusRequest +import com.example.ops_android.data.model.OrderCounts +import com.example.ops_android.data.model.PurchaseOrder + +class OrderRepository( + private val purchaseApi: PurchaseApi +) { + suspend fun getOrders(status: String? = null, search: String? = null, offset: Int = 0, limit: Int = 20): Result, Int>> { + return try { + val response = purchaseApi.getOrders(GetOrdersRequest(status, search, offset, limit)) + if (response.errCode == 0 && response.data != null) { + Result.success(Pair(response.data.orders ?: emptyList(), response.data.total)) + } else { + Result.failure(Exception("获取订单列表失败")) + } + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun getOrder(id: Int): Result { + return try { + val response = purchaseApi.getOrder(GetOrderRequest(id)) + if (response.errCode == 0 && response.data?.order != null) { + Result.success(response.data.order) + } else { + Result.failure(Exception("获取订单详情失败")) + } + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun getOrderCount(): Result { + return try { + val response = purchaseApi.getOrderCount() + if (response.errCode == 0 && response.data != null) { + Result.success(response.data) + } else { + Result.failure(Exception("获取订单统计失败")) + } + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun updateOrderStatus(id: Int, status: String, comment: String? = null, photos: List? = null): Result { + return try { + val response = purchaseApi.updateOrderStatus(UpdateOrderStatusRequest(id, status, comment, photos)) + if (response.errCode == 0) Result.success(Unit) + else Result.failure(Exception("更新订单状态失败")) + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun addOrder(data: Map): Result { + return try { + val response = purchaseApi.addOrder(data) + if (response.errCode == 0) Result.success(Unit) + else Result.failure(Exception("创建订单失败")) + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun updateOrder(data: Map): Result { + return try { + val response = purchaseApi.updateOrder(data) + if (response.errCode == 0) Result.success(Unit) + else Result.failure(Exception("更新订单失败")) + } catch (e: Exception) { + Result.failure(e) + } + } +} diff --git a/app/src/main/java/com/example/ops_android/data/repository/ScheduleRepository.kt b/app/src/main/java/com/example/ops_android/data/repository/ScheduleRepository.kt new file mode 100644 index 0000000..8ad9545 --- /dev/null +++ b/app/src/main/java/com/example/ops_android/data/repository/ScheduleRepository.kt @@ -0,0 +1,52 @@ +package com.example.ops_android.data.repository + +import com.example.ops_android.data.model.ScheduleEvent +import com.example.ops_android.data.remote.api.ScheduleApi +import com.example.ops_android.data.remote.api.GetEventsRequest + +class ScheduleRepository( + private val scheduleApi: ScheduleApi +) { + suspend fun getEvents(startDate: String? = null, endDate: String? = null): Result> { + return try { + val response = scheduleApi.getEvents(GetEventsRequest(startDate, endDate)) + if (response.errCode == 0 && response.data != null) { + Result.success(response.data.events ?: emptyList()) + } else { + Result.failure(Exception("获取日程失败")) + } + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun addEvent(data: Map): Result { + return try { + val response = scheduleApi.addEvent(data) + if (response.errCode == 0) Result.success(Unit) + else Result.failure(Exception("创建日程失败")) + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun editEvent(data: Map): Result { + return try { + val response = scheduleApi.editEvent(data) + if (response.errCode == 0) Result.success(Unit) + else Result.failure(Exception("编辑日程失败")) + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun deleteEvent(id: Int): Result { + return try { + val response = scheduleApi.deleteEvent(mapOf("id" to id)) + if (response.errCode == 0) Result.success(Unit) + else Result.failure(Exception("删除日程失败")) + } catch (e: Exception) { + Result.failure(e) + } + } +} diff --git a/app/src/main/java/com/example/ops_android/data/repository/WarehouseRepository.kt b/app/src/main/java/com/example/ops_android/data/repository/WarehouseRepository.kt new file mode 100644 index 0000000..26a12a2 --- /dev/null +++ b/app/src/main/java/com/example/ops_android/data/repository/WarehouseRepository.kt @@ -0,0 +1,150 @@ +package com.example.ops_android.data.repository + +import com.example.ops_android.data.model.Container +import com.example.ops_android.data.model.Item +import com.example.ops_android.data.model.Stats +import com.example.ops_android.data.remote.api.WarehouseApi +import com.example.ops_android.data.remote.api.ListContainerRequest +import com.example.ops_android.data.remote.api.GetContainerRequest +import com.example.ops_android.data.remote.api.ListItemRequest +import com.example.ops_android.data.remote.api.GetItemRequest +import com.example.ops_android.data.remote.api.MoveItemRequest + +class WarehouseRepository( + private val warehouseApi: WarehouseApi +) { + suspend fun listContainer(parentId: Int = 0): Result> { + return try { + val response = warehouseApi.listContainer(ListContainerRequest(parentId)) + if (response.errCode == 0 && response.data != null) { + Result.success(response.data.containers ?: emptyList()) + } else { + Result.failure(Exception("获取容器列表失败")) + } + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun getContainer(id: Int): Result { + return try { + val response = warehouseApi.getContainer(GetContainerRequest(id)) + if (response.errCode == 0 && response.data?.container != null) { + Result.success(response.data.container) + } else { + Result.failure(Exception("获取容器详情失败")) + } + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun addContainer(data: Map): Result { + return try { + val response = warehouseApi.addContainer(data) + if (response.errCode == 0) Result.success(Unit) + else Result.failure(Exception("创建容器失败")) + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun updateContainer(data: Map): Result { + return try { + val response = warehouseApi.updateContainer(data) + if (response.errCode == 0) Result.success(Unit) + else Result.failure(Exception("更新容器失败")) + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun deleteContainer(id: Int): Result { + return try { + val response = warehouseApi.deleteContainer(mapOf("id" to id)) + if (response.errCode == 0) Result.success(Unit) + else Result.failure(Exception("删除容器失败")) + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun listItem(containerId: Int = 0): Result> { + return try { + val response = warehouseApi.listItem(ListItemRequest(containerId)) + if (response.errCode == 0 && response.data != null) { + Result.success(response.data.items ?: emptyList()) + } else { + Result.failure(Exception("获取物品列表失败")) + } + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun getItem(id: Int): Result { + return try { + val response = warehouseApi.getItem(GetItemRequest(id)) + if (response.errCode == 0 && response.data?.item != null) { + Result.success(response.data.item) + } else { + Result.failure(Exception("获取物品详情失败")) + } + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun addItem(data: Map): Result { + return try { + val response = warehouseApi.addItem(data) + if (response.errCode == 0) Result.success(Unit) + else Result.failure(Exception("创建物品失败")) + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun updateItem(data: Map): Result { + return try { + val response = warehouseApi.updateItem(data) + if (response.errCode == 0) Result.success(Unit) + else Result.failure(Exception("更新物品失败")) + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun deleteItem(id: Int): Result { + return try { + val response = warehouseApi.deleteItem(mapOf("id" to id)) + if (response.errCode == 0) Result.success(Unit) + else Result.failure(Exception("删除物品失败")) + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun moveItem(itemId: Int, newContainer: Int): Result { + return try { + val response = warehouseApi.moveItem(MoveItemRequest(itemId, newContainer)) + if (response.errCode == 0) Result.success(Unit) + else Result.failure(Exception("移动物品失败")) + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun getStats(): Result { + return try { + val response = warehouseApi.getStats() + if (response.errCode == 0 && response.data != null) { + Result.success(response.data) + } else { + Result.failure(Exception("获取仓库统计失败")) + } + } catch (e: Exception) { + Result.failure(e) + } + } +} diff --git a/app/src/main/java/com/example/ops_android/data/repository/WorkOrderRepository.kt b/app/src/main/java/com/example/ops_android/data/repository/WorkOrderRepository.kt new file mode 100644 index 0000000..a8d79c0 --- /dev/null +++ b/app/src/main/java/com/example/ops_android/data/repository/WorkOrderRepository.kt @@ -0,0 +1,106 @@ +package com.example.ops_android.data.repository + +import com.example.ops_android.data.model.PurchaseOrder +import com.example.ops_android.data.model.WorkOrder +import com.example.ops_android.data.model.WorkOrderCounts +import com.example.ops_android.data.remote.api.WorkOrderApi +import com.example.ops_android.data.remote.api.WorkOrderListRequest +import com.example.ops_android.data.remote.api.WorkOrderGetRequest +import com.example.ops_android.data.remote.api.WorkOrderCommitRequest +import com.example.ops_android.data.remote.api.SearchPurchaseOrdersRequest + +class WorkOrderRepository( + private val workOrderApi: WorkOrderApi +) { + suspend fun list(status: String? = null, search: String? = null, offset: Int = 0, limit: Int = 20): Result, Int>> { + return try { + val response = workOrderApi.list(WorkOrderListRequest(status, search, offset, limit)) + if (response.errCode == 0 && response.data != null) { + Result.success(Pair(response.data.work_orders ?: emptyList(), response.data.total)) + } else { + Result.failure(Exception("获取工单列表失败")) + } + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun get(id: Int): Result { + return try { + val response = workOrderApi.get(WorkOrderGetRequest(id)) + if (response.errCode == 0 && response.data?.work_order != null) { + Result.success(response.data.work_order) + } else { + Result.failure(Exception("获取工单详情失败")) + } + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun getCount(): Result { + return try { + val response = workOrderApi.getCount() + if (response.errCode == 0 && response.data != null) { + Result.success(response.data) + } else { + Result.failure(Exception("获取工单统计失败")) + } + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun add(data: Map): Result { + return try { + val response = workOrderApi.add(data) + if (response.errCode == 0) Result.success(Unit) + else Result.failure(Exception("创建工单失败")) + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun update(data: Map): Result { + return try { + val response = workOrderApi.update(data) + if (response.errCode == 0) Result.success(Unit) + else Result.failure(Exception("更新工单失败")) + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun delete(id: Int): Result { + return try { + val response = workOrderApi.delete(mapOf("id" to id)) + if (response.errCode == 0) Result.success(Unit) + else Result.failure(Exception("删除工单失败")) + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun commit(workOrderId: Int, status: String?, comment: String?, photos: List?): Result { + return try { + val response = workOrderApi.commit(WorkOrderCommitRequest(workOrderId, status, comment, photos)) + if (response.errCode == 0) Result.success(Unit) + else Result.failure(Exception("提交流程失败")) + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun searchPurchaseOrders(query: String, limit: Int = 10): Result> { + return try { + val response = workOrderApi.searchPurchaseOrders(SearchPurchaseOrdersRequest(query, limit)) + if (response.errCode == 0 && response.data != null) { + Result.success(response.data.purchase_orders ?: emptyList()) + } else { + Result.failure(Exception("搜索采购订单失败")) + } + } catch (e: Exception) { + Result.failure(e) + } + } +} diff --git a/app/src/main/java/com/example/ops_android/printer/PrintHelper.kt b/app/src/main/java/com/example/ops_android/printer/PrintHelper.kt new file mode 100644 index 0000000..c299c13 --- /dev/null +++ b/app/src/main/java/com/example/ops_android/printer/PrintHelper.kt @@ -0,0 +1,137 @@ +package com.example.ops_android.printer + +import com.example.ops_android.data.model.Item +import com.example.ops_android.data.model.PurchaseOrder +import com.example.ops_android.data.model.WorkOrder +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +object PrintHelper { + + suspend fun printOrderLabel( + printerManager: PrinterManager, + order: PurchaseOrder + ): Result = withContext(Dispatchers.IO) { + try { + printerManager.initDevice().onFailure { return@withContext Result.failure(it) } + printerManager.setFontSize(24) + printerManager.setBold(true) + printerManager.addText("采购单: ${order.title ?: "-"}\n") + printerManager.setFontSize(20) + printerManager.setBold(false) + if (order.id > 0) { + printerManager.addBarcode("PO-${order.id}").fold( + onSuccess = {}, + onFailure = {} + ) + } + printerManager.addLineFeed(2) + printerManager.commit().fold( + onSuccess = { + Thread.sleep(1000) + printerManager.closeDevice() + Result.success(Unit) + }, + onFailure = { Result.failure(it) } + ) + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun printWorkOrderLabel( + printerManager: PrinterManager, + workOrder: WorkOrder + ): Result = withContext(Dispatchers.IO) { + try { + printerManager.initDevice().onFailure { return@withContext Result.failure(it) } + printerManager.setFontSize(24) + printerManager.setBold(true) + printerManager.addText("工单: ${workOrder.title ?: "-"}\n") + printerManager.setFontSize(20) + printerManager.setBold(false) + if (workOrder.id > 0) { + printerManager.addBarcode("WO-${workOrder.id}").fold( + onSuccess = {}, + onFailure = {} + ) + } + printerManager.addLineFeed(2) + printerManager.commit().fold( + onSuccess = { + Thread.sleep(1000) + printerManager.closeDevice() + Result.success(Unit) + }, + onFailure = { Result.failure(it) } + ) + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun printItemLabel( + printerManager: PrinterManager, + item: Item + ): Result = withContext(Dispatchers.IO) { + try { + printerManager.initDevice().onFailure { return@withContext Result.failure(it) } + printerManager.setFontSize(24) + printerManager.setBold(true) + printerManager.addText("${item.name ?: "-"}\n") + printerManager.setFontSize(18) + printerManager.setBold(false) + item.serial_number?.let { printerManager.addText("编号: $it\n") } + item.spec?.let { printerManager.addText("规格: $it\n") } + if (item.id > 0) { + printerManager.addBarcode("ITEM-${item.id}").fold( + onSuccess = {}, + onFailure = {} + ) + } + printerManager.addLineFeed(2) + printerManager.commit().fold( + onSuccess = { + Thread.sleep(1000) + printerManager.closeDevice() + Result.success(Unit) + }, + onFailure = { Result.failure(it) } + ) + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun printContainerLabel( + printerManager: PrinterManager, + containerName: String, + barcode: String? = null + ): Result = withContext(Dispatchers.IO) { + try { + printerManager.initDevice().onFailure { return@withContext Result.failure(it) } + printerManager.setFontSize(24) + printerManager.setBold(true) + printerManager.addText("容器: $containerName\n") + if (barcode != null) { + printerManager.setFontSize(18) + printerManager.setBold(false) + printerManager.addBarcode(barcode).fold( + onSuccess = {}, + onFailure = {} + ) + } + printerManager.addLineFeed(2) + printerManager.commit().fold( + onSuccess = { + Thread.sleep(1000) + printerManager.closeDevice() + Result.success(Unit) + }, + onFailure = { Result.failure(it) } + ) + } catch (e: Exception) { + Result.failure(e) + } + } +} diff --git a/app/src/main/java/com/example/ops_android/ui/AppContainerProvider.kt b/app/src/main/java/com/example/ops_android/ui/AppContainerProvider.kt new file mode 100644 index 0000000..58ad465 --- /dev/null +++ b/app/src/main/java/com/example/ops_android/ui/AppContainerProvider.kt @@ -0,0 +1,8 @@ +package com.example.ops_android.ui + +import androidx.compose.runtime.compositionLocalOf +import com.example.ops_android.AppContainer + +val LocalAppContainer = compositionLocalOf { + error("AppContainer not provided") +} diff --git a/app/src/main/java/com/example/ops_android/ui/auth/LoginScreen.kt b/app/src/main/java/com/example/ops_android/ui/auth/LoginScreen.kt new file mode 100644 index 0000000..27d99f7 --- /dev/null +++ b/app/src/main/java/com/example/ops_android/ui/auth/LoginScreen.kt @@ -0,0 +1,185 @@ +package com.example.ops_android.ui.auth + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.example.ops_android.ui.LocalAppContainer + +@Composable +fun LoginScreen( + onLoginSuccess: () -> Unit, + onGoToSettings: () -> Unit, + viewModel: LoginViewModel = viewModel(factory = LoginViewModel.Factory(LocalAppContainer.current.authRepository)) +) { + val uiState by viewModel.uiState.collectAsState() + var passwordVisible by remember { mutableStateOf(false) } + + LaunchedEffect(uiState.loginSuccess) { + if (uiState.loginSuccess) { + onLoginSuccess() + viewModel.resetLoginSuccess() + } + } + + Box(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + colors = listOf( + Color(0xFF667EEA), + Color(0xFF764BA2) + ) + ) + ), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = "OPS", + fontSize = 48.sp, + fontWeight = FontWeight.Bold, + color = Color.White + ) + + Text( + text = "运营管理系统", + fontSize = 16.sp, + color = Color.White.copy(alpha = 0.8f), + modifier = Modifier.padding(bottom = 48.dp) + ) + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 32.dp), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = Color.White) + ) { + Column( + modifier = Modifier.padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + OutlinedTextField( + value = uiState.username, + onValueChange = viewModel::onUsernameChange, + label = { Text("用户名") }, + leadingIcon = { Icon(Icons.Default.Person, contentDescription = null) }, + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + modifier = Modifier.fillMaxWidth(), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color(0xFF667EEA), + focusedLabelColor = Color(0xFF667EEA) + ) + ) + + OutlinedTextField( + value = uiState.password, + onValueChange = viewModel::onPasswordChange, + label = { Text("密码") }, + leadingIcon = { Icon(Icons.Default.Lock, contentDescription = null) }, + trailingIcon = { + IconButton(onClick = { passwordVisible = !passwordVisible }) { + Icon( + if (passwordVisible) Icons.Default.VisibilityOff else Icons.Default.Visibility, + contentDescription = null + ) + } + }, + singleLine = true, + visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done + ), + modifier = Modifier.fillMaxWidth(), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color(0xFF667EEA), + focusedLabelColor = Color(0xFF667EEA) + ) + ) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = uiState.rememberMe, + onCheckedChange = viewModel::onRememberMeChange, + colors = CheckboxDefaults.colors( + checkedColor = Color(0xFF667EEA) + ) + ) + Text( + text = "记住登录", + fontSize = 14.sp, + color = Color.Gray + ) + } + + uiState.errorMessage?.let { error -> + Text( + text = error, + color = MaterialTheme.colorScheme.error, + fontSize = 14.sp, + modifier = Modifier.padding(horizontal = 4.dp) + ) + } + + Button( + onClick = { viewModel.login() }, + modifier = Modifier + .fillMaxWidth() + .height(50.dp), + enabled = !uiState.isLoading, + shape = RoundedCornerShape(25.dp), + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFF667EEA) + ) + ) { + if (uiState.isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = Color.White, + strokeWidth = 2.dp + ) + } else { + Text("登 录", fontSize = 16.sp) + } + } + } + } + } + + TextButton( + onClick = onGoToSettings, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 32.dp) + ) { + Text("设置服务器地址", color = Color.White.copy(alpha = 0.7f)) + } + } +} diff --git a/app/src/main/java/com/example/ops_android/ui/auth/LoginViewModel.kt b/app/src/main/java/com/example/ops_android/ui/auth/LoginViewModel.kt new file mode 100644 index 0000000..256d0b0 --- /dev/null +++ b/app/src/main/java/com/example/ops_android/ui/auth/LoginViewModel.kt @@ -0,0 +1,84 @@ +package com.example.ops_android.ui.auth + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import com.example.ops_android.data.repository.AuthRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +data class LoginUiState( + val username: String = "", + val password: String = "", + val rememberMe: Boolean = true, + val isLoading: Boolean = false, + val errorMessage: String? = null, + val loginSuccess: Boolean = false, + val needSettings: Boolean = false +) + +class LoginViewModel( + private val authRepository: AuthRepository +) : ViewModel() { + + private val _uiState = MutableStateFlow(LoginUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun onUsernameChange(value: String) { + _uiState.value = _uiState.value.copy(username = value, errorMessage = null) + } + + fun onPasswordChange(value: String) { + _uiState.value = _uiState.value.copy(password = value, errorMessage = null) + } + + fun onRememberMeChange(value: Boolean) { + _uiState.value = _uiState.value.copy(rememberMe = value) + } + + fun login() { + val state = _uiState.value + if (state.username.isBlank() || state.password.isBlank()) { + _uiState.value = state.copy(errorMessage = "请输入用户名和密码") + return + } + + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null) + val result = authRepository.login(state.username, state.password, state.rememberMe) + result.fold( + onSuccess = { + _uiState.value = _uiState.value.copy(isLoading = false, loginSuccess = true) + }, + onFailure = { e -> + _uiState.value = _uiState.value.copy( + isLoading = false, + errorMessage = e.message ?: "登录失败" + ) + } + ) + } + } + + fun checkNeedSettings(): Boolean { + val result = kotlinx.coroutines.runBlocking { + authRepository.isLoggedIn() + } + return !result + } + + fun resetLoginSuccess() { + _uiState.value = _uiState.value.copy(loginSuccess = false) + } + + class Factory( + private val authRepository: AuthRepository + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + return LoginViewModel(authRepository) as T + } + } +} diff --git a/app/src/main/java/com/example/ops_android/ui/customer/CustomerListScreen.kt b/app/src/main/java/com/example/ops_android/ui/customer/CustomerListScreen.kt new file mode 100644 index 0000000..9fa7143 --- /dev/null +++ b/app/src/main/java/com/example/ops_android/ui/customer/CustomerListScreen.kt @@ -0,0 +1,116 @@ +package com.example.ops_android.ui.customer + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.compose.viewModel +import com.example.ops_android.data.model.Customer +import com.example.ops_android.data.repository.CustomerRepository +import com.example.ops_android.ui.LocalAppContainer +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +data class CustomerListUiState( + val customers: List = emptyList(), val isLoading: Boolean = false, val errorMessage: String? = null, + val showForm: Boolean = false, val editingCustomer: Customer? = null, + val formName: String = "", val formPhone: String = "", val formEmail: String = "", val formAddress: String = "", val formRemark: String = "", + val isSubmitting: Boolean = false +) + +class CustomerListViewModel(private val repo: CustomerRepository) : ViewModel() { + private val _s = MutableStateFlow(CustomerListUiState()); val s: StateFlow = _s.asStateFlow() + init { load() } + private fun load() { viewModelScope.launch { _s.value = _s.value.copy(isLoading = true); repo.list().fold(onSuccess = { _s.value = _s.value.copy(customers = it, isLoading = false) }, onFailure = { _s.value = _s.value.copy(isLoading = false, errorMessage = it.message) }) } } + + fun showAddForm() { _s.value = _s.value.copy(showForm = true, editingCustomer = null, formName = "", formPhone = "", formEmail = "", formAddress = "", formRemark = "") } + fun showEditForm(c: Customer) { _s.value = _s.value.copy(showForm = true, editingCustomer = c, formName = c.name ?: "", formPhone = c.phone ?: "", formEmail = c.email ?: "", formAddress = c.address ?: "", formRemark = c.remark ?: "") } + fun dismissForm() { _s.value = _s.value.copy(showForm = false) } + fun onFormName(v: String) { _s.value = _s.value.copy(formName = v) } + fun onFormPhone(v: String) { _s.value = _s.value.copy(formPhone = v) } + fun onFormEmail(v: String) { _s.value = _s.value.copy(formEmail = v) } + fun onFormAddress(v: String) { _s.value = _s.value.copy(formAddress = v) } + fun onFormRemark(v: String) { _s.value = _s.value.copy(formRemark = v) } + + fun submitForm() { + val st = _s.value + if (st.formName.isBlank()) { _s.value = st.copy(errorMessage = "名称不能为空"); return } + val data = mapOf("name" to st.formName, "phone" to st.formPhone, "email" to st.formEmail, "address" to st.formAddress, "remark" to st.formRemark) + viewModelScope.launch { + _s.value = _s.value.copy(isSubmitting = true) + val r = if (st.editingCustomer != null) repo.update(data + ("id" to st.editingCustomer.id)) else repo.add(data) + r.fold(onSuccess = { _s.value = _s.value.copy(isSubmitting = false, showForm = false); load() }, onFailure = { _s.value = _s.value.copy(isSubmitting = false, errorMessage = it.message) }) + } + } + + fun deleteCustomer(id: Int) { viewModelScope.launch { repo.delete(id).fold(onSuccess = { load() }, onFailure = { _s.value = _s.value.copy(errorMessage = it.message) }) } } + + class Factory(private val repo: CustomerRepository) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(c: Class): T = CustomerListViewModel(repo) as T } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CustomerListScreen(onBack: () -> Unit) { + val vm: CustomerListViewModel = viewModel(factory = CustomerListViewModel.Factory(LocalAppContainer.current.customerRepository)) + val s by vm.s.collectAsState() + + Scaffold( + topBar = { TopAppBar(title = { Text("客户管理") }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, null, tint = Color.White) } }, colors = TopAppBarDefaults.topAppBarColors(containerColor = Color(0xFF667EEA), titleContentColor = Color.White)) }, + floatingActionButton = { FloatingActionButton(onClick = { vm.showAddForm() }, containerColor = Color(0xFF667EEA)) { Icon(Icons.Default.Add, "添加", tint = Color.White) } } + ) { padding -> + if (s.isLoading) { Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { CircularProgressIndicator() }; return@Scaffold } + + LazyColumn(Modifier.fillMaxSize().padding(padding), contentPadding = PaddingValues(8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + items(s.customers, key = { it.id }) { c -> + Card(Modifier.fillMaxWidth().clickable { vm.showEditForm(c) }) { + Column(Modifier.padding(12.dp)) { + Text(c.name ?: "未命名", fontWeight = FontWeight.Medium) + c.phone?.takeIf { it.isNotBlank() }?.let { Text("电话: $it", fontSize = 13.sp, color = Color.Gray) } + c.email?.takeIf { it.isNotBlank() }?.let { Text("邮箱: $it", fontSize = 13.sp, color = Color.Gray) } + } + } + } + } + } + + if (s.showForm) AlertDialog( + onDismissRequest = { vm.dismissForm() }, + title = { Text(if (s.editingCustomer != null) "编辑客户" else "添加客户") }, + text = { + Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField(s.formName, vm::onFormName, label = { Text("名称 *") }, singleLine = true, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(s.formPhone, vm::onFormPhone, label = { Text("电话") }, singleLine = true, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(s.formEmail, vm::onFormEmail, label = { Text("邮箱") }, singleLine = true, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(s.formAddress, vm::onFormAddress, label = { Text("地址") }, singleLine = true, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(s.formRemark, vm::onFormRemark, label = { Text("备注") }, minLines = 2, modifier = Modifier.fillMaxWidth()) + } + }, + confirmButton = { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (s.editingCustomer != null) { + TextButton(onClick = { vm.deleteCustomer(s.editingCustomer!!.id) }) { Text("删除", color = Color.Red) } + } + Button(onClick = { vm.submitForm() }, enabled = !s.isSubmitting) { Text("保存") } + } + }, + dismissButton = { TextButton(onClick = { vm.dismissForm() }) { Text("取消") } } + ) +} diff --git a/app/src/main/java/com/example/ops_android/ui/home/HomeScreen.kt b/app/src/main/java/com/example/ops_android/ui/home/HomeScreen.kt new file mode 100644 index 0000000..cbcbd04 --- /dev/null +++ b/app/src/main/java/com/example/ops_android/ui/home/HomeScreen.kt @@ -0,0 +1,231 @@ +package com.example.ops_android.ui.home + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.example.ops_android.ui.LocalAppContainer + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HomeScreen( + onSearchClick: () -> Unit = {}, + onScheduleClick: () -> Unit = {}, + viewModel: HomeViewModel = viewModel(factory = HomeViewModel.Factory( + LocalAppContainer.current.orderRepository, + LocalAppContainer.current.workOrderRepository, + LocalAppContainer.current.scheduleRepository + )) +) { + val uiState by viewModel.uiState.collectAsState() + + Scaffold( + topBar = { + TopAppBar( + title = { Text("OPS 运营管理") }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Color(0xFF667EEA), + titleContentColor = Color.White + ) + ) + } + ) { padding -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + if (uiState.isLoading) { + item { + Box( + modifier = Modifier.fillMaxWidth().padding(32.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + } + + uiState.errorMessage?.let { error -> + item { + Card(colors = CardDefaults.cardColors(containerColor = Color(0xFFFFEBEE))) { + Text( + text = error, + modifier = Modifier.padding(16.dp), + color = Color(0xFFC62828) + ) + } + } + } + + // ── 今日日程 ────────────────────────── + item { + Text("今日日程", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + } + + if (uiState.todayEvents.isEmpty()) { + item { + Card(colors = CardDefaults.cardColors(containerColor = Color(0xFFF5F5F5))) { + Text( + "暂无日程安排", + modifier = Modifier.padding(16.dp), + color = Color.Gray + ) + } + } + } else { + items(uiState.todayEvents) { event -> + Card(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(Icons.Default.Event, null, tint = Color(0xFF667EEA)) + Spacer(Modifier.width(8.dp)) + Column { + Text(event.title ?: "", fontWeight = FontWeight.Medium) + Text( + "${event.start_time ?: ""} ~ ${event.end_time ?: ""}", + fontSize = 12.sp, + color = Color.Gray + ) + } + } + } + } + } + + // ── 采购订单统计 ────────────────────────── + item { + Text("采购订单", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + } + + uiState.orderCounts?.let { counts -> + item { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + StatCard("待处理", counts.pending, Color(0xFFFF9800), modifier = Modifier.weight(1f)) + StatCard("已订购", counts.ordered, Color(0xFF2196F3), modifier = Modifier.weight(1f)) + StatCard("已到货", counts.arrived, Color(0xFF4CAF50), modifier = Modifier.weight(1f)) + } + } + item { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + StatCard("已收货", counts.received, Color(0xFF4CAF50), modifier = Modifier.weight(1f)) + StatCard("丢失", counts.lost, Color(0xFFF44336), modifier = Modifier.weight(1f)) + StatCard("退货", counts.returned, Color(0xFF9C27B0), modifier = Modifier.weight(1f)) + } + } + } + + // ── 工单统计 ────────────────────────── + item { + Text("工单", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + } + + uiState.workOrderCounts?.let { counts -> + item { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + StatCard("待处理", counts.pending, Color(0xFFFF9800), modifier = Modifier.weight(1f)) + StatCard("已检查", counts.checked, Color(0xFF2196F3), modifier = Modifier.weight(1f)) + StatCard("待配件", counts.parts_ordered, Color(0xFF9C27B0), modifier = Modifier.weight(1f)) + } + } + item { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + StatCard("已修复", counts.repaired, Color(0xFF4CAF50), modifier = Modifier.weight(1f)) + StatCard("已返还", counts.returned, Color(0xFF4CAF50), modifier = Modifier.weight(1f)) + StatCard("不可修", counts.unrepairable, Color(0xFFF44336), modifier = Modifier.weight(1f)) + } + } + } + + // ── 快捷入口 ────────────────────────── + item { + Text("快捷入口", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + } + + item { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + QuickLinkCard(Icons.Default.Search, "搜索", Color(0xFF4CAF50), Modifier.weight(1f), onSearchClick) + QuickLinkCard(Icons.Default.Event, "日程", Color(0xFF9C27B0), Modifier.weight(1f), onScheduleClick) + } + } + } + } +} + +@Composable +private fun StatCard(label: String, count: Int, color: Color, modifier: Modifier = Modifier) { + Card( + modifier = modifier, + colors = CardDefaults.cardColors(containerColor = color.copy(alpha = 0.1f)), + shape = RoundedCornerShape(12.dp) + ) { + Column( + modifier = Modifier.padding(12.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "$count", + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + color = color + ) + Text( + text = label, + fontSize = 12.sp, + color = Color.Gray + ) + } + } +} + +@Composable +private fun QuickLinkCard(icon: ImageVector, label: String, color: Color, modifier: Modifier = Modifier, onClick: () -> Unit = {}) { + Card( + modifier = modifier.clickable { onClick() }, + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors(containerColor = color.copy(alpha = 0.1f)) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon(icon, null, tint = color, modifier = Modifier.size(32.dp)) + Spacer(Modifier.height(8.dp)) + Text(label, fontSize = 14.sp, color = Color.Gray) + } + } +} diff --git a/app/src/main/java/com/example/ops_android/ui/home/HomeViewModel.kt b/app/src/main/java/com/example/ops_android/ui/home/HomeViewModel.kt new file mode 100644 index 0000000..985c15a --- /dev/null +++ b/app/src/main/java/com/example/ops_android/ui/home/HomeViewModel.kt @@ -0,0 +1,94 @@ +package com.example.ops_android.ui.home + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import com.example.ops_android.data.model.OrderCounts +import com.example.ops_android.data.model.ScheduleEvent +import com.example.ops_android.data.model.WorkOrderCounts +import com.example.ops_android.data.repository.OrderRepository +import com.example.ops_android.data.repository.ScheduleRepository +import com.example.ops_android.data.repository.WorkOrderRepository +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import java.text.SimpleDateFormat +import java.util.* + +data class HomeUiState( + val todayEvents: List = emptyList(), + val orderCounts: OrderCounts? = null, + val workOrderCounts: WorkOrderCounts? = null, + val isLoading: Boolean = false, + val errorMessage: String? = null +) + +class HomeViewModel( + private val orderRepository: OrderRepository, + private val workOrderRepository: WorkOrderRepository, + private val scheduleRepository: ScheduleRepository +) : ViewModel() { + + private val _uiState = MutableStateFlow(HomeUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var autoRefreshJob: Job? = null + + init { + loadData() + startAutoRefresh() + } + + fun loadData() { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null) + try { + val today = SimpleDateFormat("yyyy-MM-dd", Locale.US).format(Date()) + val eventsResult = scheduleRepository.getEvents(today, today) + val orderCounts = orderRepository.getOrderCount() + val workOrderCounts = workOrderRepository.getCount() + + _uiState.value = _uiState.value.copy( + todayEvents = eventsResult.getOrNull() ?: emptyList(), + orderCounts = orderCounts.getOrNull(), + workOrderCounts = workOrderCounts.getOrNull(), + isLoading = false + ) + } catch (e: Exception) { + _uiState.value = _uiState.value.copy( + isLoading = false, + errorMessage = e.message + ) + } + } + } + + private fun startAutoRefresh() { + autoRefreshJob = viewModelScope.launch { + while (isActive) { + delay(5000) + loadData() + } + } + } + + override fun onCleared() { + super.onCleared() + autoRefreshJob?.cancel() + } + + class Factory( + private val orderRepository: OrderRepository, + private val workOrderRepository: WorkOrderRepository, + private val scheduleRepository: ScheduleRepository + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + return HomeViewModel(orderRepository, workOrderRepository, scheduleRepository) as T + } + } +} diff --git a/app/src/main/java/com/example/ops_android/ui/navigation/OPSNavGraph.kt b/app/src/main/java/com/example/ops_android/ui/navigation/OPSNavGraph.kt new file mode 100644 index 0000000..814cbec --- /dev/null +++ b/app/src/main/java/com/example/ops_android/ui/navigation/OPSNavGraph.kt @@ -0,0 +1,200 @@ +package com.example.ops_android.ui.navigation + +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.Inventory +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.ShoppingCart +import androidx.compose.material.icons.filled.Warehouse +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.navigation.NavDestination.Companion.hierarchy +import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument +import com.example.ops_android.ui.auth.LoginScreen +import com.example.ops_android.ui.customer.CustomerListScreen +import com.example.ops_android.ui.home.HomeScreen +import com.example.ops_android.ui.order.OrderDetailScreen +import com.example.ops_android.ui.order.OrderFormScreen +import com.example.ops_android.ui.order.OrderListScreen +import com.example.ops_android.ui.profile.ProfileScreen +import com.example.ops_android.ui.schedule.ScheduleScreen +import com.example.ops_android.ui.search.SearchScreen +import com.example.ops_android.ui.settings.SettingsScreen +import com.example.ops_android.ui.warehouse.ItemDetailScreen +import com.example.ops_android.ui.warehouse.ItemFormScreen +import com.example.ops_android.ui.warehouse.WarehouseScreen +import com.example.ops_android.ui.workorder.WorkOrderDetailScreen +import com.example.ops_android.ui.workorder.WorkOrderFormScreen +import com.example.ops_android.ui.workorder.WorkOrderListScreen + +object Routes { + const val LOGIN = "login" + const val MAIN = "main" + const val SETTINGS = "settings" + const val SEARCH = "search" + const val SCHEDULE = "schedule" + const val ORDER_DETAIL = "order_detail/{orderId}" + const val ORDER_FORM = "order_form?editId={editId}" + const val WORK_ORDER_DETAIL = "work_order_detail/{id}" + const val WORK_ORDER_FORM = "work_order_form?editId={editId}" + const val ITEM_DETAIL = "item_detail/{id}" + const val ITEM_FORM = "item_form/{containerId}?editId={editId}" + const val CUSTOMER_LIST = "customer_list" + + fun orderDetail(orderId: Int) = "order_detail/$orderId" + fun orderForm(editId: Int? = null) = if (editId != null) "order_form?editId=$editId" else "order_form" + fun workOrderDetail(id: Int) = "work_order_detail/$id" + fun workOrderForm(editId: Int? = null) = if (editId != null) "work_order_form?editId=$editId" else "work_order_form" + fun itemDetail(id: Int) = "item_detail/$id" + fun itemForm(containerId: Int, editId: Int? = null) = if (editId != null) "item_form/$containerId?editId=$editId" else "item_form/$containerId" +} + +object BottomTabs { + const val HOME = "home" + const val ORDERS = "orders" + const val WORK_ORDERS = "work_orders" + const val WAREHOUSE = "warehouse" + const val PROFILE = "profile" +} + +data class BottomNavItem(val route: String, val icon: ImageVector, val label: String) + +val bottomNavItems = listOf( + BottomNavItem(BottomTabs.HOME, Icons.Default.Home, "首页"), + BottomNavItem(BottomTabs.ORDERS, Icons.Default.ShoppingCart, "订单"), + BottomNavItem(BottomTabs.WORK_ORDERS, Icons.Default.Inventory, "工单"), + BottomNavItem(BottomTabs.WAREHOUSE, Icons.Default.Warehouse, "仓库"), + BottomNavItem(BottomTabs.PROFILE, Icons.Default.Person, "我的") +) + +@Composable +fun OPSNavGraph(isLoggedIn: Boolean) { + val rootNavController = rememberNavController() + + NavHost(navController = rootNavController, startDestination = if (isLoggedIn) Routes.MAIN else Routes.LOGIN) { + composable(Routes.LOGIN) { + LoginScreen( + onLoginSuccess = { rootNavController.navigate(Routes.MAIN) { popUpTo(Routes.LOGIN) { inclusive = true } } }, + onGoToSettings = { rootNavController.navigate(Routes.SETTINGS) } + ) + } + composable(Routes.MAIN) { + MainScreen( + onLogout = { rootNavController.navigate(Routes.LOGIN) { popUpTo(Routes.MAIN) { inclusive = true } } }, + onNavigate = { route -> rootNavController.navigate(route) } + ) + } + composable(Routes.SETTINGS) { + SettingsScreen(onBack = { rootNavController.popBackStack() }) + } + composable(Routes.SEARCH) { + SearchScreen( + onOrderClick = { id -> rootNavController.navigate(Routes.orderDetail(id)) }, + onWorkOrderClick = { id -> rootNavController.navigate(Routes.workOrderDetail(id)) }, + onItemClick = { id -> rootNavController.navigate(Routes.itemDetail(id)) }, + onContainerClick = { id -> rootNavController.navigate(Routes.MAIN) }, + onBack = { rootNavController.popBackStack() } + ) + } + composable(Routes.SCHEDULE) { + ScheduleScreen(onBack = { rootNavController.popBackStack() }) + } + composable(Routes.ORDER_DETAIL, arguments = listOf(navArgument("orderId") { type = NavType.IntType })) { backStackEntry -> + val orderId = backStackEntry.arguments?.getInt("orderId") ?: return@composable + OrderDetailScreen(orderId = orderId, onBack = { rootNavController.popBackStack() }) + } + composable(Routes.ORDER_FORM, arguments = listOf(navArgument("editId") { type = NavType.IntType; defaultValue = -1 })) { backStackEntry -> + val editId = backStackEntry.arguments?.getInt("editId")?.takeIf { it > 0 } + OrderFormScreen(editOrderId = editId, onBack = { rootNavController.popBackStack() }, onSaved = { rootNavController.popBackStack() }) + } + composable(Routes.WORK_ORDER_DETAIL, arguments = listOf(navArgument("id") { type = NavType.IntType })) { backStackEntry -> + val id = backStackEntry.arguments?.getInt("id") ?: return@composable + WorkOrderDetailScreen(id = id, onBack = { rootNavController.popBackStack() }) + } + composable(Routes.WORK_ORDER_FORM, arguments = listOf(navArgument("editId") { type = NavType.IntType; defaultValue = -1 })) { backStackEntry -> + val editId = backStackEntry.arguments?.getInt("editId")?.takeIf { it > 0 } + WorkOrderFormScreen(editId = editId, onBack = { rootNavController.popBackStack() }, onSaved = { rootNavController.popBackStack() }) + } + composable(Routes.ITEM_DETAIL, arguments = listOf(navArgument("id") { type = NavType.IntType })) { backStackEntry -> + val id = backStackEntry.arguments?.getInt("id") ?: return@composable + ItemDetailScreen(id = id, onBack = { rootNavController.popBackStack() }) + } + composable(Routes.ITEM_FORM, arguments = listOf(navArgument("containerId") { type = NavType.IntType }, navArgument("editId") { type = NavType.IntType; defaultValue = -1 })) { backStackEntry -> + val containerId = backStackEntry.arguments?.getInt("containerId") ?: 0 + val editId = backStackEntry.arguments?.getInt("editId")?.takeIf { it > 0 } + ItemFormScreen(containerId = containerId, editId = editId, onBack = { rootNavController.popBackStack() }, onSaved = { rootNavController.popBackStack() }) + } + composable(Routes.CUSTOMER_LIST) { + CustomerListScreen(onBack = { rootNavController.popBackStack() }) + } + } +} + +@Composable +fun MainScreen(onLogout: () -> Unit, onNavigate: (String) -> Unit) { + val tabNavController = rememberNavController() + + Scaffold( + bottomBar = { + NavigationBar { + val navBackStackEntry by tabNavController.currentBackStackEntryAsState() + val currentDestination = navBackStackEntry?.destination + bottomNavItems.forEach { item -> + NavigationBarItem( + icon = { Icon(item.icon, contentDescription = item.label) }, + label = { Text(item.label) }, + selected = currentDestination?.hierarchy?.any { it.route == item.route } == true, + onClick = { + tabNavController.navigate(item.route) { + popUpTo(tabNavController.graph.findStartDestination().id) { saveState = true } + launchSingleTop = true + restoreState = true + } + } + ) + } + } + } + ) { innerPadding -> + NavHost(navController = tabNavController, startDestination = BottomTabs.HOME, modifier = Modifier.padding(innerPadding)) { + composable(BottomTabs.HOME) { + HomeScreen( + onSearchClick = { onNavigate(Routes.SEARCH) }, + onScheduleClick = { onNavigate(Routes.SCHEDULE) } + ) + } + composable(BottomTabs.ORDERS) { + OrderListScreen( + onOrderClick = { id -> onNavigate(Routes.orderDetail(id)) }, + onAddClick = { onNavigate(Routes.orderForm()) } + ) + } + composable(BottomTabs.WORK_ORDERS) { + WorkOrderListScreen( + onWorkOrderClick = { id -> onNavigate(Routes.workOrderDetail(id)) }, + onAddClick = { onNavigate(Routes.workOrderForm()) } + ) + } + composable(BottomTabs.WAREHOUSE) { + WarehouseScreen( + onItemClick = { id -> onNavigate(Routes.itemDetail(id)) } + ) + } + composable(BottomTabs.PROFILE) { + ProfileScreen( + onLogout = onLogout, + onGoToSettings = { onNavigate(Routes.SETTINGS) } + ) + } + } + } +} diff --git a/app/src/main/java/com/example/ops_android/ui/order/OrderDetailScreen.kt b/app/src/main/java/com/example/ops_android/ui/order/OrderDetailScreen.kt new file mode 100644 index 0000000..d58b4b4 --- /dev/null +++ b/app/src/main/java/com/example/ops_android/ui/order/OrderDetailScreen.kt @@ -0,0 +1,247 @@ +package com.example.ops_android.ui.order + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Print +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.example.ops_android.ui.LocalAppContainer +import kotlinx.coroutines.launch + +val STATUS_FLOW = listOf("pending", "ordered", "arrived", "received") +val STATUS_FLOW_LABELS = mapOf( + "pending" to "待处理", + "ordered" to "已订购", + "arrived" to "已到货", + "received" to "已收货", + "lost" to "丢失", + "returned" to "退货" +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun OrderDetailScreen( + orderId: Int, + onBack: () -> Unit +) { + val repository = LocalAppContainer.current.orderRepository + val viewModel: OrderDetailViewModel = viewModel( + key = "order_$orderId", + factory = OrderDetailViewModel.Factory(repository, orderId) + ) + val uiState by viewModel.uiState.collectAsState() + + val order = uiState.order + + Scaffold( + topBar = { + TopAppBar( + title = { Text("订单详情") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, null, tint = Color.White) + } + }, + actions = { + val scope = rememberCoroutineScope() + order?.let { o -> + val printerManager = LocalAppContainer.current.printerManager + if (printerManager != null) { + IconButton(onClick = { + scope.launch { + com.example.ops_android.printer.PrintHelper.printOrderLabel(printerManager, o) + } + }) { + Icon(Icons.Default.Print, "打印", tint = Color.White) + } + } + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Color(0xFF667EEA), + titleContentColor = Color.White + ) + ) + } + ) { padding -> + if (uiState.isLoading) { + Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + return@Scaffold + } + + if (order == null) { + Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { + Text(uiState.errorMessage ?: "加载失败") + } + return@Scaffold + } + + LazyColumn( + modifier = Modifier.fillMaxSize().padding(padding).padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + item { + Text("基本信息", fontWeight = FontWeight.Bold, fontSize = 18.sp) + Card(modifier = Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp)) { + DetailRow("标题", order.title ?: "-") + DetailRow("状态", STATUS_FLOW_LABELS[order.status] ?: order.status ?: "-") + DetailRow("备注", order.remark ?: "-") + DetailRow("链接", order.link ?: "-") + DetailRow("创建时间", order.created_at ?: "-") + } + } + } + + order.style_tags?.let { tags -> + if (tags.isNotEmpty()) { + item { + Text("款式标签", fontWeight = FontWeight.Bold, fontSize = 16.sp) + Card(modifier = Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp)) { + tags.forEach { Text("- $it", fontSize = 14.sp) } + } + } + } + } + } + + order.costs?.let { costs -> + if (costs.isNotEmpty()) { + item { + Text("费用明细", fontWeight = FontWeight.Bold, fontSize = 16.sp) + Card(modifier = Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp)) { + costs.forEach { cost -> + Row( + Modifier.fillMaxWidth().padding(vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text("${cost.amount} ${cost.currency}") + Text(cost.remark ?: "", color = Color.Gray) + } + } + } + } + } + } + } + + // Status timeline + order.status_logs?.let { logs -> + if (logs.isNotEmpty()) { + item { + Text("状态时间线", fontWeight = FontWeight.Bold, fontSize = 16.sp) + } + items(logs) { log -> + TimelineItem(log) + } + } + } + + // Status change buttons + item { + Spacer(Modifier.height(8.dp)) + Text("状态变更", fontWeight = FontWeight.Bold, fontSize = 16.sp) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + STATUS_FLOW.forEach { status -> + if (status != order.status) { + FilterChip( + selected = false, + onClick = { viewModel.showStatusDialog(status) }, + label = { Text(STATUS_FLOW_LABELS[status] ?: status, fontSize = 12.sp) } + ) + } + } + FilterChip( + selected = false, + onClick = { viewModel.showStatusDialog("lost") }, + label = { Text("丢失", fontSize = 12.sp, color = Color(0xFFF44336)) } + ) + FilterChip( + selected = false, + onClick = { viewModel.showStatusDialog("returned") }, + label = { Text("退货", fontSize = 12.sp, color = Color(0xFF9C27B0)) } + ) + } + } + } + } + + // Status change dialog + if (uiState.showStatusDialog) { + val statusLabel = STATUS_FLOW_LABELS[uiState.selectedStatus] ?: uiState.selectedStatus + AlertDialog( + onDismissRequest = { viewModel.dismissStatusDialog() }, + title = { Text("变更状态为「$statusLabel」") }, + text = { + Column { + Text("请输入备注(可选):") + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = uiState.statusComment, + onValueChange = viewModel::onStatusCommentChange, + modifier = Modifier.fillMaxWidth(), + minLines = 2, + placeholder = { Text("备注信息") } + ) + } + }, + confirmButton = { + Button( + onClick = { viewModel.submitStatusChange() }, + enabled = !uiState.isSubmitting + ) { Text("确认") } + }, + dismissButton = { + TextButton(onClick = { viewModel.dismissStatusDialog() }) { Text("取消") } + } + ) + } +} + +@Composable +private fun DetailRow(label: String, value: String) { + Row(Modifier.fillMaxWidth().padding(vertical = 4.dp)) { + Text(label, color = Color.Gray, modifier = Modifier.width(80.dp)) + Text(value) + } +} + +@Composable +private fun TimelineItem(log: com.example.ops_android.data.model.StatusLog) { + Card(modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(8.dp)) { + Column(Modifier.padding(12.dp)) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text( + STATUS_FLOW_LABELS[log.status] ?: log.status ?: "", + fontWeight = FontWeight.Medium, + color = Color(0xFF667EEA) + ) + Text(log.created_at ?: "", fontSize = 12.sp, color = Color.Gray) + } + log.comment?.let { + if (it.isNotBlank()) { + Spacer(Modifier.height(4.dp)) + Text(it, fontSize = 14.sp) + } + } + } + } +} diff --git a/app/src/main/java/com/example/ops_android/ui/order/OrderDetailViewModel.kt b/app/src/main/java/com/example/ops_android/ui/order/OrderDetailViewModel.kt new file mode 100644 index 0000000..19533fc --- /dev/null +++ b/app/src/main/java/com/example/ops_android/ui/order/OrderDetailViewModel.kt @@ -0,0 +1,98 @@ +package com.example.ops_android.ui.order + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import com.example.ops_android.data.model.Cost +import com.example.ops_android.data.model.PurchaseOrder +import com.example.ops_android.data.model.StatusLog +import com.example.ops_android.data.repository.OrderRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +data class OrderDetailUiState( + val order: PurchaseOrder? = null, + val isLoading: Boolean = false, + val errorMessage: String? = null, + val showStatusDialog: Boolean = false, + val selectedStatus: String = "", + val statusComment: String = "", + val isSubmitting: Boolean = false +) + +class OrderDetailViewModel( + private val orderRepository: OrderRepository, + private val orderId: Int +) : ViewModel() { + + private val _uiState = MutableStateFlow(OrderDetailUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + loadOrder() + } + + fun loadOrder() { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true) + val result = orderRepository.getOrder(orderId) + result.fold( + onSuccess = { order -> + _uiState.value = _uiState.value.copy(order = order, isLoading = false) + }, + onFailure = { e -> + _uiState.value = _uiState.value.copy(isLoading = false, errorMessage = e.message) + } + ) + } + } + + fun showStatusDialog(status: String) { + _uiState.value = _uiState.value.copy( + showStatusDialog = true, + selectedStatus = status, + statusComment = "" + ) + } + + fun dismissStatusDialog() { + _uiState.value = _uiState.value.copy(showStatusDialog = false) + } + + fun onStatusCommentChange(comment: String) { + _uiState.value = _uiState.value.copy(statusComment = comment) + } + + fun submitStatusChange() { + viewModelScope.launch { + val state = _uiState.value + _uiState.value = state.copy(isSubmitting = true) + val result = orderRepository.updateOrderStatus( + id = orderId, + status = state.selectedStatus, + comment = state.statusComment.ifBlank { null } + ) + result.fold( + onSuccess = { + _uiState.value = _uiState.value.copy(showStatusDialog = false, isSubmitting = false) + loadOrder() + }, + onFailure = { e -> + _uiState.value = _uiState.value.copy(isSubmitting = false, errorMessage = e.message) + } + ) + } + } + + class Factory( + private val orderRepository: OrderRepository, + private val orderId: Int + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + return OrderDetailViewModel(orderRepository, orderId) as T + } + } +} diff --git a/app/src/main/java/com/example/ops_android/ui/order/OrderFormScreen.kt b/app/src/main/java/com/example/ops_android/ui/order/OrderFormScreen.kt new file mode 100644 index 0000000..e26ed6e --- /dev/null +++ b/app/src/main/java/com/example/ops_android/ui/order/OrderFormScreen.kt @@ -0,0 +1,198 @@ +package com.example.ops_android.ui.order + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.compose.viewModel +import com.example.ops_android.data.repository.OrderRepository +import com.example.ops_android.ui.LocalAppContainer +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +data class OrderFormUiState( + val title: String = "", + val remark: String = "", + val link: String = "", + val styleTags: String = "", + val costs: List> = listOf("" to ""), + val isSubmitting: Boolean = false, + val errorMessage: String? = null, + val success: Boolean = false +) + +class OrderFormViewModel( + private val orderRepository: OrderRepository, + private val editOrderId: Int? = null +) : ViewModel() { + private val _uiState = MutableStateFlow(OrderFormUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun onTitleChange(v: String) { _uiState.value = _uiState.value.copy(title = v) } + fun onRemarkChange(v: String) { _uiState.value = _uiState.value.copy(remark = v) } + fun onLinkChange(v: String) { _uiState.value = _uiState.value.copy(link = v) } + fun onStyleTagsChange(v: String) { _uiState.value = _uiState.value.copy(styleTags = v) } + + fun addCost() { + _uiState.value = _uiState.value.copy(costs = _uiState.value.costs + ("" to "")) + } + + fun updateCostAmount(index: Int, value: String) { + val costs = _uiState.value.costs.toMutableList() + costs[index] = costs[index].copy(first = value) + _uiState.value = _uiState.value.copy(costs = costs) + } + + fun updateCostCurrency(index: Int, value: String) { + val costs = _uiState.value.costs.toMutableList() + costs[index] = costs[index].copy(second = value) + _uiState.value = _uiState.value.copy(costs = costs) + } + + fun removeCost(index: Int) { + val costs = _uiState.value.costs.toMutableList() + if (costs.size > 1) { + costs.removeAt(index) + _uiState.value = _uiState.value.copy(costs = costs) + } + } + + fun submit() { + val state = _uiState.value + if (state.title.isBlank()) { + _uiState.value = state.copy(errorMessage = "标题不能为空") + return + } + + val costList = state.costs + .filter { it.first.isNotBlank() } + .map { mapOf("amount" to it.first.toDoubleOrNull(), "currency" to it.second.ifBlank { "CNY" }) } + + val tags = state.styleTags + .split(",", ",") + .map { it.trim() } + .filter { it.isNotBlank() } + + val data = mapOf( + "title" to state.title, + "remark" to state.remark, + "link" to state.link, + "style_tags" to tags, + "costs" to costList + ) + + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isSubmitting = true, errorMessage = null) + val result = if (editOrderId != null) { + orderRepository.updateOrder(data + ("id" to editOrderId)) + } else { + orderRepository.addOrder(data) + } + result.fold( + onSuccess = { _uiState.value = _uiState.value.copy(isSubmitting = false, success = true) }, + onFailure = { e -> _uiState.value = _uiState.value.copy(isSubmitting = false, errorMessage = e.message) } + ) + } + } + + class Factory( + private val orderRepository: OrderRepository, + private val editOrderId: Int? = null + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + return OrderFormViewModel(orderRepository, editOrderId) as T + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun OrderFormScreen( + editOrderId: Int? = null, + onBack: () -> Unit, + onSaved: () -> Unit +) { + val viewModel: OrderFormViewModel = viewModel( + factory = OrderFormViewModel.Factory(LocalAppContainer.current.orderRepository, editOrderId) + ) + val uiState by viewModel.uiState.collectAsState() + + LaunchedEffect(uiState.success) { + if (uiState.success) onSaved() + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(if (editOrderId != null) "编辑订单" else "新建订单") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, null, tint = Color.White) + } + }, + colors = TopAppBarDefaults.topAppBarColors(containerColor = Color(0xFF667EEA), titleContentColor = Color.White) + ) + } + ) { padding -> + Column( + modifier = Modifier.fillMaxSize().padding(padding).padding(16.dp).verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + OutlinedTextField(value = uiState.title, onValueChange = viewModel::onTitleChange, label = { Text("标题 *") }, modifier = Modifier.fillMaxWidth(), singleLine = true) + OutlinedTextField(value = uiState.remark, onValueChange = viewModel::onRemarkChange, label = { Text("备注") }, modifier = Modifier.fillMaxWidth(), minLines = 2) + OutlinedTextField(value = uiState.link, onValueChange = viewModel::onLinkChange, label = { Text("链接") }, modifier = Modifier.fillMaxWidth(), singleLine = true) + OutlinedTextField(value = uiState.styleTags, onValueChange = viewModel::onStyleTagsChange, label = { Text("款式标签(逗号分隔)") }, modifier = Modifier.fillMaxWidth(), singleLine = true) + + Text("费用明细", fontWeight = FontWeight.Bold, fontSize = 16.sp) + + uiState.costs.forEachIndexed { index, cost -> + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + OutlinedTextField( + value = cost.first, onValueChange = { viewModel.updateCostAmount(index, it) }, + label = { Text("金额") }, modifier = Modifier.weight(1f), singleLine = true + ) + OutlinedTextField( + value = cost.second, onValueChange = { viewModel.updateCostCurrency(index, it) }, + label = { Text("币种") }, modifier = Modifier.weight(0.5f), singleLine = true, + placeholder = { Text("CNY") } + ) + if (uiState.costs.size > 1) { + IconButton(onClick = { viewModel.removeCost(index) }) { + Text("✕", color = Color.Red) + } + } + } + } + + TextButton(onClick = { viewModel.addCost() }) { Text("+ 添加费用") } + + uiState.errorMessage?.let { + Text(it, color = Color.Red, fontSize = 14.sp) + } + + Button( + onClick = { viewModel.submit() }, + modifier = Modifier.fillMaxWidth().height(48.dp), + enabled = !uiState.isSubmitting + ) { + if (uiState.isSubmitting) CircularProgressIndicator(Modifier.size(20.dp), color = Color.White, strokeWidth = 2.dp) + else Text("提交") + } + } + } +} diff --git a/app/src/main/java/com/example/ops_android/ui/order/OrderListScreen.kt b/app/src/main/java/com/example/ops_android/ui/order/OrderListScreen.kt new file mode 100644 index 0000000..d1e0753 --- /dev/null +++ b/app/src/main/java/com/example/ops_android/ui/order/OrderListScreen.kt @@ -0,0 +1,170 @@ +package com.example.ops_android.ui.order + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.example.ops_android.data.model.PurchaseOrder +import com.example.ops_android.ui.LocalAppContainer + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun OrderListScreen( + onOrderClick: (Int) -> Unit, + onAddClick: () -> Unit, + viewModel: OrderListViewModel = viewModel(factory = OrderListViewModel.Factory(LocalAppContainer.current.orderRepository)) +) { + val uiState by viewModel.uiState.collectAsState() + + Scaffold( + topBar = { + TopAppBar( + title = { Text("采购订单") }, + actions = { + IconButton(onClick = onAddClick) { + Icon(Icons.Default.Add, "新建", tint = Color.White) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Color(0xFF667EEA), + titleContentColor = Color.White + ) + ) + }, + floatingActionButton = { + FloatingActionButton( + onClick = onAddClick, + containerColor = Color(0xFF667EEA) + ) { + Icon(Icons.Default.Add, "新建", tint = Color.White) + } + } + ) { padding -> + Column(modifier = Modifier.padding(padding)) { + // Status filter tabs + ScrollableTabRow( + selectedTabIndex = ORDER_STATUSES.indexOfFirst { it.first == uiState.selectedStatus }.coerceAtLeast(0), + modifier = Modifier.fillMaxWidth(), + edgePadding = 8.dp + ) { + ORDER_STATUSES.forEachIndexed { index, (status, label) -> + Tab( + selected = uiState.selectedStatus == status, + onClick = { viewModel.selectStatus(status) }, + text = { + Text( + label, + fontSize = 13.sp, + fontWeight = if (uiState.selectedStatus == status) FontWeight.Bold else FontWeight.Normal + ) + } + ) + } + } + + when { + uiState.isLoading -> { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } + uiState.errorMessage != null -> { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text(uiState.errorMessage!!, color = Color.Red) + Spacer(Modifier.height(8.dp)) + Button(onClick = { viewModel.refresh() }) { Text("重试") } + } + } + } + else -> { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(uiState.orders, key = { it.id }) { order -> + OrderListItem( + order = order, + onClick = { onOrderClick(order.id) } + ) + } + + if (uiState.isLoadingMore) { + item { + Box(Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) { + CircularProgressIndicator(modifier = Modifier.size(24.dp)) + } + } + } + + // infinite scroll trigger + item { + LaunchedEffect(Unit) { viewModel.loadMore() } + } + } + } + } + } + } +} + +@Composable +private fun OrderListItem(order: PurchaseOrder, onClick: () -> Unit) { + val statusColors = mapOf( + "pending" to Color(0xFFFF9800), + "ordered" to Color(0xFF2196F3), + "arrived" to Color(0xFF03A9F4), + "received" to Color(0xFF4CAF50), + "lost" to Color(0xFFF44336), + "returned" to Color(0xFF9C27B0) + ) + val statusLabels = mapOf( + "pending" to "待处理", + "ordered" to "已订购", + "arrived" to "已到货", + "received" to "已收货", + "lost" to "丢失", + "returned" to "退货" + ) + + Card( + modifier = Modifier + .fillMaxWidth() + .clickable { onClick() } + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text(order.title ?: "无标题", fontWeight = FontWeight.Medium, fontSize = 15.sp) + Spacer(Modifier.height(4.dp)) + Text(order.created_at ?: "", fontSize = 12.sp, color = Color.Gray) + } + Spacer(Modifier.width(8.dp)) + Surface( + shape = MaterialTheme.shapes.small, + color = statusColors[order.status]?.copy(alpha = 0.15f) ?: Color.Gray.copy(alpha = 0.15f) + ) { + Text( + statusLabels[order.status] ?: order.status ?: "", + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + fontSize = 12.sp, + color = statusColors[order.status] ?: Color.Gray + ) + } + } + } +} diff --git a/app/src/main/java/com/example/ops_android/ui/order/OrderListViewModel.kt b/app/src/main/java/com/example/ops_android/ui/order/OrderListViewModel.kt new file mode 100644 index 0000000..4db4ec4 --- /dev/null +++ b/app/src/main/java/com/example/ops_android/ui/order/OrderListViewModel.kt @@ -0,0 +1,143 @@ +package com.example.ops_android.ui.order + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import com.example.ops_android.data.model.PurchaseOrder +import com.example.ops_android.data.repository.OrderRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +val ORDER_STATUSES = listOf( + "" to "全部", + "pending" to "待处理", + "ordered" to "已订购", + "arrived" to "已到货", + "received" to "已收货", + "lost" to "丢失", + "returned" to "退货" +) + +data class OrderListUiState( + val orders: List = emptyList(), + val selectedStatus: String = "", + val isLoading: Boolean = false, + val isRefreshing: Boolean = false, + val isLoadingMore: Boolean = false, + val hasMore: Boolean = true, + val errorMessage: String? = null +) + +class OrderListViewModel( + private val orderRepository: OrderRepository +) : ViewModel() { + + private val _uiState = MutableStateFlow(OrderListUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val pageSize = 20 + private var currentOffset = 0 + + init { + loadOrders() + } + + fun selectStatus(status: String) { + _uiState.value = _uiState.value.copy(selectedStatus = status) + refresh() + } + + fun refresh() { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isRefreshing = true, orders = emptyList()) + currentOffset = 0 + val state = _uiState.value + val result = orderRepository.getOrders( + status = state.selectedStatus.ifEmpty { null }, + offset = 0, + limit = pageSize + ) + result.fold( + onSuccess = { (orders, total) -> + _uiState.value = _uiState.value.copy( + orders = orders, + isRefreshing = false, + isLoading = false, + hasMore = orders.size >= pageSize, + errorMessage = null + ) + }, + onFailure = { e -> + _uiState.value = _uiState.value.copy( + isRefreshing = false, + isLoading = false, + errorMessage = e.message + ) + } + ) + } + } + + fun loadMore() { + val state = _uiState.value + if (state.isLoadingMore || !state.hasMore) return + + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoadingMore = true) + currentOffset += pageSize + val result = orderRepository.getOrders( + status = state.selectedStatus.ifEmpty { null }, + offset = currentOffset, + limit = pageSize + ) + result.fold( + onSuccess = { (orders, total) -> + _uiState.value = _uiState.value.copy( + orders = _uiState.value.orders + orders, + isLoadingMore = false, + hasMore = orders.size >= pageSize + ) + }, + onFailure = { e -> + _uiState.value = _uiState.value.copy( + isLoadingMore = false, + errorMessage = e.message + ) + } + ) + } + } + + private fun loadOrders() { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true) + val result = orderRepository.getOrders(limit = pageSize) + result.fold( + onSuccess = { (orders, total) -> + _uiState.value = _uiState.value.copy( + orders = orders, + isLoading = false, + hasMore = orders.size >= pageSize + ) + }, + onFailure = { e -> + _uiState.value = _uiState.value.copy( + isLoading = false, + errorMessage = e.message + ) + } + ) + } + } + + class Factory( + private val orderRepository: OrderRepository + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + return OrderListViewModel(orderRepository) as T + } + } +} diff --git a/app/src/main/java/com/example/ops_android/ui/profile/ProfileScreen.kt b/app/src/main/java/com/example/ops_android/ui/profile/ProfileScreen.kt new file mode 100644 index 0000000..008842b --- /dev/null +++ b/app/src/main/java/com/example/ops_android/ui/profile/ProfileScreen.kt @@ -0,0 +1,166 @@ +package com.example.ops_android.ui.profile + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.automirrored.filled.Logout +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.example.ops_android.ui.LocalAppContainer + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ProfileScreen( + onLogout: () -> Unit, + onGoToSettings: () -> Unit, + viewModel: ProfileViewModel = viewModel(factory = ProfileViewModel.Factory( + LocalAppContainer.current.authRepository, + LocalAppContainer.current.sessionManager + )) +) { + val uiState by viewModel.uiState.collectAsState() + var showLogoutDialog by remember { mutableStateOf(false) } + + LaunchedEffect(uiState.isLoggedOut) { + if (uiState.isLoggedOut) { + onLogout() + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("我的") }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Color(0xFF667EEA), + titleContentColor = Color.White + ) + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(200.dp) + .background( + Brush.verticalGradient( + colors = listOf(Color(0xFF667EEA), Color(0xFF764BA2)) + ) + ), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Box( + modifier = Modifier + .size(80.dp) + .clip(CircleShape) + .background(Color.White.copy(alpha = 0.3f)), + contentAlignment = Alignment.Center + ) { + Icon( + Icons.Default.Person, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = Color.White + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + Text( + text = viewModel.username.ifEmpty { "未登录" }, + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + color = Color.White + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) { + Column(modifier = Modifier.padding(16.dp)) { + ProfileInfoRow("用户名", viewModel.username) + ProfileInfoRow("姓名", uiState.userInfo?.FirstName ?: "-") + ProfileInfoRow("角色", uiState.userInfo?.Role ?: "-") + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + NavigationDrawerItem( + icon = { Icon(Icons.Default.Settings, contentDescription = null) }, + label = { Text("设置") }, + selected = false, + onClick = onGoToSettings, + modifier = Modifier.padding(horizontal = 16.dp) + ) + + NavigationDrawerItem( + icon = { Icon(Icons.AutoMirrored.Filled.Logout, contentDescription = null, tint = Color(0xFFF44336)) }, + label = { Text("退出登录", color = Color(0xFFF44336)) }, + selected = false, + onClick = { showLogoutDialog = true }, + modifier = Modifier.padding(horizontal = 16.dp) + ) + } + } + + if (showLogoutDialog) { + AlertDialog( + onDismissRequest = { showLogoutDialog = false }, + title = { Text("退出登录") }, + text = { Text("确定要退出登录吗?") }, + confirmButton = { + TextButton(onClick = { + showLogoutDialog = false + viewModel.logout() + }) { + Text("确定", color = Color(0xFFF44336)) + } + }, + dismissButton = { + TextButton(onClick = { showLogoutDialog = false }) { + Text("取消") + } + } + ) + } +} + +@Composable +private fun ProfileInfoRow(label: String, value: String) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text(text = label, color = Color.Gray) + Text(text = value, fontWeight = FontWeight.Medium) + } +} diff --git a/app/src/main/java/com/example/ops_android/ui/profile/ProfileViewModel.kt b/app/src/main/java/com/example/ops_android/ui/profile/ProfileViewModel.kt new file mode 100644 index 0000000..6462989 --- /dev/null +++ b/app/src/main/java/com/example/ops_android/ui/profile/ProfileViewModel.kt @@ -0,0 +1,84 @@ +package com.example.ops_android.ui.profile + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import com.example.ops_android.data.local.SessionManager +import com.example.ops_android.data.model.UserDetail +import com.example.ops_android.data.model.UserInfo +import com.example.ops_android.data.repository.AuthRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +data class ProfileUiState( + val user: UserInfo? = null, + val userInfo: UserDetail? = null, + val isLoading: Boolean = false, + val errorMessage: String? = null, + val isLoggedOut: Boolean = false +) + +class ProfileViewModel( + private val authRepository: AuthRepository, + private val sessionManager: SessionManager +) : ViewModel() { + + private val _uiState = MutableStateFlow(ProfileUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + loadProfile() + } + + fun loadProfile() { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true) + val result = authRepository.getUserInfo() + result.fold( + onSuccess = { (user, userInfo) -> + _uiState.value = _uiState.value.copy( + user = user, + userInfo = userInfo, + isLoading = false, + errorMessage = null + ) + }, + onFailure = { e -> + _uiState.value = _uiState.value.copy( + isLoading = false, + errorMessage = e.message ?: "加载失败" + ) + } + ) + } + } + + fun logout() { + viewModelScope.launch { + authRepository.logout() + _uiState.value = _uiState.value.copy(isLoggedOut = true) + } + } + + val username: String + get() { + val info = _uiState.value.userInfo + val user = _uiState.value.user + return info?.Username ?: user?.Name ?: "" + } + + val avatarPath: String? + get() = _uiState.value.userInfo?.AvatarPath + + class Factory( + private val authRepository: AuthRepository, + private val sessionManager: SessionManager + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + return ProfileViewModel(authRepository, sessionManager) as T + } + } +} diff --git a/app/src/main/java/com/example/ops_android/ui/schedule/ScheduleScreen.kt b/app/src/main/java/com/example/ops_android/ui/schedule/ScheduleScreen.kt new file mode 100644 index 0000000..2c866aa --- /dev/null +++ b/app/src/main/java/com/example/ops_android/ui/schedule/ScheduleScreen.kt @@ -0,0 +1,158 @@ +package com.example.ops_android.ui.schedule + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.example.ops_android.ui.LocalAppContainer +import java.text.SimpleDateFormat +import java.util.* + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ScheduleScreen( + onBack: () -> Unit +) { + val vm: ScheduleViewModel = viewModel(factory = ScheduleViewModel.Factory(LocalAppContainer.current.scheduleRepository)) + val s by vm.uiState.collectAsState() + val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.US) + + Scaffold( + topBar = { + TopAppBar( + title = { Text("日程管理") }, + navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, null, tint = Color.White) } }, + actions = { IconButton(onClick = { vm.showAddForm(dateFormat.format(Date())) }) { Icon(Icons.Default.Add, "添加", tint = Color.White) } }, + colors = TopAppBarDefaults.topAppBarColors(containerColor = Color(0xFF667EEA), titleContentColor = Color.White) + ) + } + ) { padding -> + LazyColumn(Modifier.fillMaxSize().padding(padding)) { + // Month header + item { + Card(Modifier.fillMaxWidth().padding(12.dp)) { + Column(Modifier.padding(8.dp)) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = { vm.prevMonth() }) { Icon(Icons.AutoMirrored.Filled.KeyboardArrowLeft, "上月") } + Text("${s.currentYear}年 ${s.currentMonth + 1}月", fontWeight = FontWeight.Bold, fontSize = 18.sp) + IconButton(onClick = { vm.nextMonth() }) { Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, "下月") } + } + + // Weekday headers + Row(Modifier.fillMaxWidth()) { + listOf("日", "一", "二", "三", "四", "五", "六").forEach { day -> + Text(day, Modifier.weight(1f), textAlign = TextAlign.Center, fontSize = 12.sp, color = Color.Gray) + } + } + Spacer(Modifier.height(4.dp)) + + // Calendar grid + val cal = Calendar.getInstance().apply { set(s.currentYear, s.currentMonth, 1) } + val firstDayOfWeek = cal.get(Calendar.DAY_OF_WEEK) - 1 + val daysInMonth = cal.getActualMaximum(Calendar.DAY_OF_MONTH) + val today = SimpleDateFormat("yyyy-MM-dd", Locale.US).format(Date()) + + var dayCount = 0 + val weeks = mutableListOf>() + var currentWeek = mutableListOf() + for (i in 0 until firstDayOfWeek) { currentWeek.add(null); dayCount++ } + for (day in 1..daysInMonth) { currentWeek.add(day); dayCount++; if (dayCount % 7 == 0) { weeks.add(currentWeek.toList()); currentWeek = mutableListOf() } } + if (currentWeek.isNotEmpty()) { while (currentWeek.size < 7) currentWeek.add(null); weeks.add(currentWeek.toList()) } + + weeks.forEach { week -> + Row(Modifier.fillMaxWidth()) { + week.forEach { day -> + val cellModifier = Modifier.weight(1f).aspectRatio(1f).padding(2.dp) + if (day != null) { + val dateStr = String.format("%04d-%02d-%02d", s.currentYear, s.currentMonth + 1, day) + val hasEvent = s.events.any { it.start_time?.startsWith(dateStr) == true } + val isToday = dateStr == today + + Box( + cellModifier.clip(CircleShape) + .then(if (isToday) Modifier.background(Color(0xFF667EEA)) else Modifier) + .clickable { vm.showAddForm(dateStr) }, + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text(day.toString(), fontSize = 13.sp, color = if (isToday) Color.White else Color.Black) + if (hasEvent) Box(Modifier.size(4.dp).clip(CircleShape).background(if (isToday) Color.White else Color(0xFF667EEA))) + } + } + } else { + Spacer(cellModifier) + } + } + } + } + } + } + } + + // Events list + item { Text("日程列表", fontWeight = FontWeight.Bold, fontSize = 16.sp, modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) } + + if (s.events.isEmpty()) { + item { Box(Modifier.fillMaxWidth().padding(32.dp), contentAlignment = Alignment.Center) { Text("暂无日程", color = Color.Gray) } } + } else { + items(s.events.sortedBy { it.start_time }) { event -> + Card(Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp).clickable { vm.showEditForm(event) }) { + Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text(event.title ?: "", fontWeight = FontWeight.Medium) + val timeStr = listOfNotNull(event.start_time?.substring(11)?.take(5), event.end_time?.substring(11)?.take(5)).joinToString(" ~ ") + if (timeStr.isNotBlank()) Text(timeStr, fontSize = 12.sp, color = Color.Gray) + } + } + } + } + } + } + } + + // Form dialog + if (s.showForm) AlertDialog( + onDismissRequest = { vm.dismissForm() }, + title = { Text(if (s.editingEvent != null) "编辑日程" else "添加日程") }, + text = { + Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField(s.formTitle, vm::onFormTitle, label = { Text("标题 *") }, singleLine = true, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(s.formDescription, vm::onFormDesc, label = { Text("描述") }, minLines = 2, modifier = Modifier.fillMaxWidth()) + Text("日期: ${s.formDate}", fontSize = 14.sp, color = Color.Gray) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField(s.formStartTime, vm::onFormStartTime, label = { Text("开始") }, placeholder = { Text("HH:mm") }, singleLine = true, modifier = Modifier.weight(1f)) + OutlinedTextField(s.formEndTime, vm::onFormEndTime, label = { Text("结束") }, placeholder = { Text("HH:mm") }, singleLine = true, modifier = Modifier.weight(1f)) + } + } + }, + confirmButton = { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (s.editingEvent != null) { + TextButton(onClick = { vm.deleteEvent(s.editingEvent!!.id); vm.dismissForm() }) { Text("删除", color = Color.Red) } + } + Button(onClick = { vm.submitForm() }, enabled = !s.isSubmitting) { Text("保存") } + } + }, + dismissButton = { TextButton(onClick = { vm.dismissForm() }) { Text("取消") } } + ) +} diff --git a/app/src/main/java/com/example/ops_android/ui/schedule/ScheduleViewModel.kt b/app/src/main/java/com/example/ops_android/ui/schedule/ScheduleViewModel.kt new file mode 100644 index 0000000..7c885f9 --- /dev/null +++ b/app/src/main/java/com/example/ops_android/ui/schedule/ScheduleViewModel.kt @@ -0,0 +1,113 @@ +package com.example.ops_android.ui.schedule + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import com.example.ops_android.data.model.ScheduleEvent +import com.example.ops_android.data.repository.ScheduleRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.text.SimpleDateFormat +import java.util.* + +data class ScheduleUiState( + val events: List = emptyList(), + val currentYear: Int = Calendar.getInstance().get(Calendar.YEAR), + val currentMonth: Int = Calendar.getInstance().get(Calendar.MONTH), + val isLoading: Boolean = false, + val errorMessage: String? = null, + val showForm: Boolean = false, + val editingEvent: ScheduleEvent? = null, + val formTitle: String = "", + val formDescription: String = "", + val formDate: String = SimpleDateFormat("yyyy-MM-dd", Locale.US).format(Date()), + val formStartTime: String = "", + val formEndTime: String = "", + val isSubmitting: Boolean = false +) + +class ScheduleViewModel( + private val repository: ScheduleRepository +) : ViewModel() { + private val _uiState = MutableStateFlow(ScheduleUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + private val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.US) + + init { loadEvents() } + + fun loadEvents() { + val state = _uiState.value + val cal = Calendar.getInstance() + cal.set(state.currentYear, state.currentMonth, 1) + val start = dateFormat.format(cal.time) + cal.set(Calendar.DAY_OF_MONTH, cal.getActualMaximum(Calendar.DAY_OF_MONTH)) + val end = dateFormat.format(cal.time) + + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true) + repository.getEvents(start, end).fold( + onSuccess = { _uiState.value = _uiState.value.copy(events = it, isLoading = false) }, + onFailure = { _uiState.value = _uiState.value.copy(isLoading = false, errorMessage = it.message) } + ) + } + } + + fun prevMonth() { + val state = _uiState.value + _uiState.value = state.copy( + currentMonth = if (state.currentMonth == 0) { _uiState.value = _uiState.value.copy(currentYear = state.currentYear - 1); 11 } else state.currentMonth - 1 + ) + loadEvents() + } + + fun nextMonth() { + val state = _uiState.value + _uiState.value = state.copy( + currentMonth = if (state.currentMonth == 11) { _uiState.value = _uiState.value.copy(currentYear = state.currentYear + 1); 0 } else state.currentMonth + 1 + ) + loadEvents() + } + + fun showAddForm(date: String) { + _uiState.value = _uiState.value.copy(showForm = true, editingEvent = null, formTitle = "", formDescription = "", formDate = date, formStartTime = "", formEndTime = "") + } + + fun showEditForm(event: ScheduleEvent) { + _uiState.value = _uiState.value.copy( + showForm = true, editingEvent = event, + formTitle = event.title ?: "", formDescription = event.description ?: "", + formDate = event.start_time?.take(10) ?: "", formStartTime = event.start_time?.substring(11)?.take(5) ?: "", + formEndTime = event.end_time?.substring(11)?.take(5) ?: "" + ) + } + + fun dismissForm() { _uiState.value = _uiState.value.copy(showForm = false) } + fun onFormTitle(v: String) { _uiState.value = _uiState.value.copy(formTitle = v) } + fun onFormDesc(v: String) { _uiState.value = _uiState.value.copy(formDescription = v) } + fun onFormStartTime(v: String) { _uiState.value = _uiState.value.copy(formStartTime = v) } + fun onFormEndTime(v: String) { _uiState.value = _uiState.value.copy(formEndTime = v) } + + fun submitForm() { + val st = _uiState.value + if (st.formTitle.isBlank()) { _uiState.value = st.copy(errorMessage = "标题不能为空"); return } + val startTime = if (st.formStartTime.isNotBlank()) "${st.formDate}T${st.formStartTime}:00" else st.formDate + val endTime = if (st.formEndTime.isNotBlank()) "${st.formDate}T${st.formEndTime}:00" else st.formDate + val data = mapOf("title" to st.formTitle, "description" to st.formDescription, "start_time" to startTime, "end_time" to endTime) + + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isSubmitting = true) + val r = if (st.editingEvent != null) repository.editEvent(data + ("id" to st.editingEvent.id)) else repository.addEvent(data) + r.fold(onSuccess = { _uiState.value = _uiState.value.copy(showForm = false, isSubmitting = false); loadEvents() }, onFailure = { _uiState.value = _uiState.value.copy(isSubmitting = false, errorMessage = it.message) }) + } + } + + fun deleteEvent(id: Int) { + viewModelScope.launch { repository.deleteEvent(id).fold(onSuccess = { loadEvents() }, onFailure = { _uiState.value = _uiState.value.copy(errorMessage = it.message) }) } + } + + class Factory(private val repo: ScheduleRepository) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") override fun create(c: Class): T = ScheduleViewModel(repo) as T + } +} diff --git a/app/src/main/java/com/example/ops_android/ui/search/SearchScreen.kt b/app/src/main/java/com/example/ops_android/ui/search/SearchScreen.kt new file mode 100644 index 0000000..610e49f --- /dev/null +++ b/app/src/main/java/com/example/ops_android/ui/search/SearchScreen.kt @@ -0,0 +1,321 @@ +package com.example.ops_android.ui.search + +import android.Manifest +import android.content.pm.PackageManager +import android.util.Size +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.ImageProxy +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.PreviewView +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.QrCodeScanner +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.ContextCompat +import androidx.lifecycle.viewmodel.compose.viewModel +import com.example.ops_android.data.model.Container +import com.example.ops_android.data.model.Item +import com.example.ops_android.data.model.PurchaseOrder +import com.example.ops_android.data.model.WorkOrder +import com.example.ops_android.ui.LocalAppContainer +import com.google.mlkit.vision.barcode.BarcodeScanner +import com.google.mlkit.vision.barcode.BarcodeScanning +import com.google.mlkit.vision.barcode.common.Barcode +import com.google.mlkit.vision.common.InputImage + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SearchScreen( + onOrderClick: (Int) -> Unit, + onWorkOrderClick: (Int) -> Unit, + onItemClick: (Int) -> Unit, + onContainerClick: (Int) -> Unit, + onBack: () -> Unit +) { + val appContainer = LocalAppContainer.current + val viewModel: SearchViewModel = viewModel( + factory = SearchViewModel.Factory(appContainer.orderRepository, appContainer.workOrderRepository, appContainer.warehouseRepository) + ) + val uiState by viewModel.uiState.collectAsState() + var showScanner by remember { mutableStateOf(false) } + val context = LocalContext.current + var hasCameraPermission by remember { mutableStateOf(false) } + + val permissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + hasCameraPermission = granted + if (granted) showScanner = true + } + + fun checkCameraAndScan() { + if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) { + hasCameraPermission = true + showScanner = true + } else { + permissionLauncher.launch(Manifest.permission.CAMERA) + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("搜索") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, null, tint = Color.White) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Color(0xFF667EEA), + titleContentColor = Color.White + ) + ) + } + ) { padding -> + if (showScanner) { + BarcodeScannerView( + onBarcodeScanned = { barcode -> + viewModel.onBarcodeScanned(barcode) + showScanner = false + }, + onClose = { showScanner = false } + ) + return@Scaffold + } + + Column(modifier = Modifier.fillMaxSize().padding(padding)) { + // Search bar + Row( + modifier = Modifier.fillMaxWidth().padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedTextField( + value = uiState.query, + onValueChange = viewModel::onQueryChange, + modifier = Modifier.weight(1f), + placeholder = { Text("输入关键词搜索...", fontSize = 14.sp) }, + singleLine = true, + trailingIcon = { + IconButton(onClick = { viewModel.search() }) { + Icon(Icons.Default.Search, "搜索") + } + }, + shape = RoundedCornerShape(24.dp) + ) + IconButton(onClick = { checkCameraAndScan() }) { + Icon(Icons.Default.QrCodeScanner, "扫码", tint = Color(0xFF667EEA)) + } + } + + // Prefix hints + Text( + "前缀快捷搜索: po:采购单 wo:工单 item:物品 warehouse:容器", + fontSize = 11.sp, + color = Color.Gray, + modifier = Modifier.padding(horizontal = 16.dp), + textAlign = TextAlign.Center + ) + + // Category tabs + ScrollableTabRow( + selectedTabIndex = SearchCategory.entries.indexOf(uiState.category), + modifier = Modifier.fillMaxWidth(), + edgePadding = 8.dp + ) { + SearchCategory.entries.forEach { cat -> + Tab( + selected = uiState.category == cat, + onClick = { viewModel.selectCategory(cat); viewModel.clearResults() }, + text = { Text(cat.label, fontSize = 13.sp) } + ) + } + } + + if (uiState.isSearching) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + return@Column + } + + // Results + LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + val hasResults = uiState.orders.isNotEmpty() || uiState.workOrders.isNotEmpty() || uiState.items.isNotEmpty() || uiState.containers.isNotEmpty() + + if (!hasResults && uiState.query.isNotBlank()) { + item { + Box(Modifier.fillMaxWidth().padding(32.dp), contentAlignment = Alignment.Center) { + Text("未找到匹配结果", color = Color.Gray) + } + } + } + + if (uiState.orders.isNotEmpty() && (uiState.category == SearchCategory.ALL || uiState.category == SearchCategory.ORDER)) { + item { CategoryHeader("采购订单") } + items(uiState.orders.take(5)) { order -> SearchOrderItem(order) { onOrderClick(order.id) } } + } + + if (uiState.workOrders.isNotEmpty() && (uiState.category == SearchCategory.ALL || uiState.category == SearchCategory.WORK_ORDER)) { + item { CategoryHeader("工单") } + items(uiState.workOrders.take(5)) { wo -> SearchWorkOrderItem(wo) { onWorkOrderClick(wo.id) } } + } + + if (uiState.items.isNotEmpty() && (uiState.category == SearchCategory.ALL || uiState.category == SearchCategory.ITEM)) { + item { CategoryHeader("物品") } + items(uiState.items.take(5)) { item -> SearchItemItem(item) { onItemClick(item.id) } } + } + + if (uiState.containers.isNotEmpty() && (uiState.category == SearchCategory.ALL || uiState.category == SearchCategory.CONTAINER)) { + item { CategoryHeader("容器") } + items(uiState.containers.take(5)) { container -> SearchContainerItem(container) { onContainerClick(container.id) } } + } + } + } + } +} + +@Composable +private fun CategoryHeader(title: String) { + Text(title, fontWeight = FontWeight.Bold, fontSize = 14.sp, color = Color(0xFF667EEA), modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) +} + +@Composable +private fun SearchOrderItem(order: PurchaseOrder, onClick: () -> Unit) { + Card(Modifier.fillMaxWidth().clickable { onClick() }) { + Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text(order.title ?: "无标题", fontWeight = FontWeight.Medium) + order.remark?.takeIf { it.isNotBlank() }?.let { Text(it, fontSize = 12.sp, color = Color.Gray, maxLines = 1) } + } + } + } +} + +@Composable +private fun SearchWorkOrderItem(wo: WorkOrder, onClick: () -> Unit) { + Card(Modifier.fillMaxWidth().clickable { onClick() }) { + Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text(wo.title ?: "无标题", fontWeight = FontWeight.Medium) + wo.description?.takeIf { it.isNotBlank() }?.let { Text(it, fontSize = 12.sp, color = Color.Gray, maxLines = 1) } + } + } + } +} + +@Composable +private fun SearchItemItem(item: Item, onClick: () -> Unit) { + Card(Modifier.fillMaxWidth().clickable { onClick() }) { + Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text(item.name ?: "未命名", fontWeight = FontWeight.Medium) + item.serial_number?.let { Text("编号: $it", fontSize = 12.sp, color = Color.Gray) } + } + } + } +} + +@Composable +private fun SearchContainerItem(container: Container, onClick: () -> Unit) { + Card(Modifier.fillMaxWidth().clickable { onClick() }) { + Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text(container.name ?: "未命名", fontWeight = FontWeight.Medium) + container.barcode?.let { Text("条码: $it", fontSize = 12.sp, color = Color.Gray) } + } + } + } +} + +@Composable +fun BarcodeScannerView( + onBarcodeScanned: (String) -> Unit, + onClose: () -> Unit +) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + var hasScanned by remember { mutableStateOf(false) } + + Box(modifier = Modifier.fillMaxSize().background(Color.Black)) { + AndroidView( + factory = { ctx -> + PreviewView(ctx).also { previewView -> + val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx) + cameraProviderFuture.addListener({ + val cameraProvider = cameraProviderFuture.get() + val preview = androidx.camera.core.Preview.Builder().build().also { + it.setSurfaceProvider(previewView.surfaceProvider) + } + + val scanner = BarcodeScanning.getClient() + val imageAnalysis = ImageAnalysis.Builder() + .setTargetResolution(Size(1280, 720)) + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build() + imageAnalysis.setAnalyzer(ContextCompat.getMainExecutor(ctx)) { imageProxy -> + if (!hasScanned) { + processImageProxy(scanner, imageProxy) { barcode -> + hasScanned = true + onBarcodeScanned(barcode) + } + } else { + imageProxy.close() + } + } + + val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA + try { + cameraProvider.unbindAll() + cameraProvider.bindToLifecycle(lifecycleOwner, cameraSelector, preview, imageAnalysis) + } catch (e: Exception) { } + }, ContextCompat.getMainExecutor(ctx)) + } + }, + modifier = Modifier.fillMaxSize() + ) + + Button( + onClick = onClose, + modifier = Modifier.align(Alignment.TopEnd).padding(16.dp) + ) { + Text("关闭") + } + } +} + +private fun processImageProxy(scanner: BarcodeScanner, imageProxy: ImageProxy, onResult: (String) -> Unit) { + val mediaImage = imageProxy.image + if (mediaImage != null) { + val image = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees) + scanner.process(image) + .addOnSuccessListener { barcodes -> + for (barcode in barcodes) { + barcode.rawValue?.let { onResult(it) } + } + } + .addOnCompleteListener { imageProxy.close() } + } else { + imageProxy.close() + } +} diff --git a/app/src/main/java/com/example/ops_android/ui/search/SearchViewModel.kt b/app/src/main/java/com/example/ops_android/ui/search/SearchViewModel.kt new file mode 100644 index 0000000..0321490 --- /dev/null +++ b/app/src/main/java/com/example/ops_android/ui/search/SearchViewModel.kt @@ -0,0 +1,153 @@ +package com.example.ops_android.ui.search + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import com.example.ops_android.data.model.Container +import com.example.ops_android.data.model.Item +import com.example.ops_android.data.model.PurchaseOrder +import com.example.ops_android.data.model.WorkOrder +import com.example.ops_android.data.repository.CustomerRepository +import com.example.ops_android.data.repository.OrderRepository +import com.example.ops_android.data.repository.WarehouseRepository +import com.example.ops_android.data.repository.WorkOrderRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +enum class SearchCategory(val label: String) { + ALL("全部"), + ORDER("订单"), + WORK_ORDER("工单"), + ITEM("物品"), + CONTAINER("容器") +} + +data class SearchUiState( + val query: String = "", + val category: SearchCategory = SearchCategory.ALL, + val orders: List = emptyList(), + val workOrders: List = emptyList(), + val items: List = emptyList(), + val containers: List = emptyList(), + val isSearching: Boolean = false, + val errorMessage: String? = null +) + +class SearchViewModel( + private val orderRepository: OrderRepository, + private val workOrderRepository: WorkOrderRepository, + private val warehouseRepository: WarehouseRepository +) : ViewModel() { + + private val _uiState = MutableStateFlow(SearchUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun onQueryChange(value: String) { + _uiState.value = _uiState.value.copy(query = value) + } + + fun selectCategory(category: SearchCategory) { + _uiState.value = _uiState.value.copy(category = category) + } + + fun onBarcodeScanned(barcode: String) { + _uiState.value = _uiState.value.copy(query = barcode) + search() + } + + fun search() { + val query = _uiState.value.query.trim() + if (query.isEmpty()) return + + // Parse prefix shortcuts + val (actualCategory, actualQuery) = parsePrefix(query) + _uiState.value = _uiState.value.copy(category = actualCategory) + + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isSearching = true, errorMessage = null) + + when (actualCategory) { + SearchCategory.ALL -> { + launch { orderRepository.getOrders(search = actualQuery, limit = 10).fold( + onSuccess = { (list, _) -> _uiState.value = _uiState.value.copy(orders = list) }, + onFailure = { } + )} + launch { workOrderRepository.list(search = actualQuery, limit = 10).fold( + onSuccess = { (list, _) -> _uiState.value = _uiState.value.copy(workOrders = list) }, + onFailure = { } + )} + launch { warehouseRepository.listItem().fold( + onSuccess = { _uiState.value = _uiState.value.copy(items = it.filter { item -> item.name?.contains(actualQuery, true) == true || item.serial_number?.contains(actualQuery, true) == true || item.barcode?.contains(actualQuery, true) == true }.take(10)) }, + onFailure = { } + )} + launch { warehouseRepository.listContainer().fold( + onSuccess = { _uiState.value = _uiState.value.copy(containers = it.filter { c -> c.name?.contains(actualQuery, true) == true || c.barcode?.contains(actualQuery, true) == true }.take(10)) }, + onFailure = { } + )} + } + SearchCategory.ORDER -> { + orderRepository.getOrders(search = actualQuery, limit = 20).fold( + onSuccess = { (list, _) -> _uiState.value = _uiState.value.copy(orders = list) }, + onFailure = { _uiState.value = _uiState.value.copy(errorMessage = it.message) } + ) + } + SearchCategory.WORK_ORDER -> { + workOrderRepository.list(search = actualQuery, limit = 20).fold( + onSuccess = { (list, _) -> _uiState.value = _uiState.value.copy(workOrders = list) }, + onFailure = { _uiState.value = _uiState.value.copy(errorMessage = it.message) } + ) + } + SearchCategory.ITEM -> { + warehouseRepository.listItem().fold( + onSuccess = { items -> + _uiState.value = _uiState.value.copy(items = items.filter { it.name?.contains(actualQuery, true) == true || it.serial_number?.contains(actualQuery, true) == true || it.barcode?.contains(actualQuery, true) == true }) + }, + onFailure = { _uiState.value = _uiState.value.copy(errorMessage = it.message) } + ) + } + SearchCategory.CONTAINER -> { + warehouseRepository.listContainer().fold( + onSuccess = { containers -> + _uiState.value = _uiState.value.copy(containers = containers.filter { it.name?.contains(actualQuery, true) == true || it.barcode?.contains(actualQuery, true) == true }) + }, + onFailure = { _uiState.value = _uiState.value.copy(errorMessage = it.message) } + ) + } + } + + _uiState.value = _uiState.value.copy(isSearching = false) + } + } + + fun clearResults() { + _uiState.value = SearchUiState(query = _uiState.value.query, category = _uiState.value.category) + } + + private fun parsePrefix(query: String): Pair { + val prefixes = mapOf( + "po:" to SearchCategory.ORDER, + "wo:" to SearchCategory.WORK_ORDER, + "item:" to SearchCategory.ITEM, + "warehouse:" to SearchCategory.CONTAINER + ) + for ((prefix, category) in prefixes) { + if (query.startsWith(prefix, ignoreCase = true)) { + return category to query.removeRange(0, prefix.length).trim() + } + } + return _uiState.value.category to query + } + + class Factory( + private val orderRepository: OrderRepository, + private val workOrderRepository: WorkOrderRepository, + private val warehouseRepository: WarehouseRepository + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + return SearchViewModel(orderRepository, workOrderRepository, warehouseRepository) as T + } + } +} diff --git a/app/src/main/java/com/example/ops_android/ui/settings/SettingsScreen.kt b/app/src/main/java/com/example/ops_android/ui/settings/SettingsScreen.kt new file mode 100644 index 0000000..a0fd6bc --- /dev/null +++ b/app/src/main/java/com/example/ops_android/ui/settings/SettingsScreen.kt @@ -0,0 +1,152 @@ +package com.example.ops_android.ui.settings + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.example.ops_android.BuildConfig +import com.example.ops_android.ui.LocalAppContainer + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen( + onBack: () -> Unit, + viewModel: SettingsViewModel = viewModel(factory = SettingsViewModel.Factory(LocalAppContainer.current.sessionManager)) +) { + val uiState by viewModel.uiState.collectAsState() + + Scaffold( + topBar = { + TopAppBar( + title = { Text("设置") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回") + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Color(0xFF667EEA), + titleContentColor = Color.White, + navigationIconContentColor = Color.White + ) + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(16.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = "服务器配置", + style = MaterialTheme.typography.titleMedium + ) + + OutlinedTextField( + value = uiState.apiBaseUrl, + onValueChange = viewModel::onUrlChange, + label = { Text("API 地址") }, + placeholder = { Text("例如: http://192.168.1.100:8080/api") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Button( + onClick = { viewModel.saveUrl() }, + modifier = Modifier.weight(1f) + ) { + Text("保存") + } + + Button( + onClick = { viewModel.testConnection() }, + modifier = Modifier.weight(1f), + enabled = !uiState.isTesting, + colors = ButtonDefaults.buttonColors( + containerColor = if (uiState.testSuccess) Color(0xFF4CAF50) + else if (uiState.testResult != null && !uiState.testSuccess) Color(0xFFF44336) + else MaterialTheme.colorScheme.secondary + ) + ) { + if (uiState.isTesting) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + color = Color.White, + strokeWidth = 2.dp + ) + } else { + Text("测试连接") + } + } + } + + uiState.message?.let { + Text( + text = it, + color = MaterialTheme.colorScheme.primary, + fontSize = 14.sp + ) + } + + uiState.testResult?.let { + Card( + colors = CardDefaults.cardColors( + containerColor = if (uiState.testSuccess) Color(0xFFE8F5E9) else Color(0xFFFFEBEE) + ) + ) { + Text( + text = it, + modifier = Modifier.padding(12.dp), + color = if (uiState.testSuccess) Color(0xFF2E7D32) else Color(0xFFC62828), + fontSize = 14.sp + ) + } + } + + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + Text( + text = "关于", + style = MaterialTheme.typography.titleMedium + ) + + Card { + Column(modifier = Modifier.padding(16.dp)) { + InfoRow("应用名称", "OPS 运营管理系统") + InfoRow("版本", "v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})") + InfoRow("作者", BuildConfig.AUTHOR) + InfoRow("构建日期", BuildConfig.BUILD_DATE) + } + } + } + } +} + +@Composable +private fun InfoRow(label: String, value: String) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text(text = label, color = Color.Gray) + Text(text = value) + } +} diff --git a/app/src/main/java/com/example/ops_android/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/example/ops_android/ui/settings/SettingsViewModel.kt new file mode 100644 index 0000000..75db92b --- /dev/null +++ b/app/src/main/java/com/example/ops_android/ui/settings/SettingsViewModel.kt @@ -0,0 +1,132 @@ +package com.example.ops_android.ui.settings + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import com.example.ops_android.data.local.SessionManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import java.net.ConnectException +import java.net.SocketTimeoutException +import java.net.UnknownHostException +import java.util.concurrent.TimeUnit +import javax.net.ssl.SSLException + +data class SettingsUiState( + val apiBaseUrl: String = "", + val isTesting: Boolean = false, + val testResult: String? = null, + val testSuccess: Boolean = false, + val message: String? = null +) + +class SettingsViewModel( + private val sessionManager: SessionManager +) : ViewModel() { + + private val _uiState = MutableStateFlow(SettingsUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val client = OkHttpClient.Builder() + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(10, TimeUnit.SECONDS) + .followRedirects(true) + .build() + + init { + loadCurrentUrl() + } + + private fun loadCurrentUrl() { + viewModelScope.launch { + val url = sessionManager.getApiBaseUrl() + _uiState.value = _uiState.value.copy(apiBaseUrl = url) + } + } + + fun onUrlChange(value: String) { + _uiState.value = _uiState.value.copy(apiBaseUrl = value, message = null) + } + + fun saveUrl() { + viewModelScope.launch { + val url = _uiState.value.apiBaseUrl.trim() + if (url.isBlank()) { + _uiState.value = _uiState.value.copy(message = "请输入有效的API地址") + return@launch + } + sessionManager.setApiBaseUrl(url) + _uiState.value = _uiState.value.copy(message = "API地址已保存") + } + } + + fun testConnection() { + viewModelScope.launch { + val url = _uiState.value.apiBaseUrl.trim() + if (url.isBlank()) { + _uiState.value = _uiState.value.copy(message = "请先输入API地址") + return@launch + } + + _uiState.value = _uiState.value.copy(isTesting = true, testResult = null, message = null) + + try { + val code = withContext(Dispatchers.IO) { + val request = Request.Builder().url(url).header("Accept", "application/json").build() + val response = client.newCall(request).execute() + response.use { it.code } + } + _uiState.value = _uiState.value.copy( + isTesting = false, + testResult = if (code in 200..299) "连接成功 ($code)" else "服务器响应异常: HTTP $code", + testSuccess = code in 200..299 + ) + } catch (e: UnknownHostException) { + _uiState.value = _uiState.value.copy( + isTesting = false, + testResult = "连接失败: 无法解析服务器地址", + testSuccess = false + ) + } catch (e: SocketTimeoutException) { + _uiState.value = _uiState.value.copy( + isTesting = false, + testResult = "连接超时,请检查网络和服务器地址", + testSuccess = false + ) + } catch (e: ConnectException) { + _uiState.value = _uiState.value.copy( + isTesting = false, + testResult = "连接被拒绝: ${e.message ?: "服务器不可达"}", + testSuccess = false + ) + } catch (e: SSLException) { + _uiState.value = _uiState.value.copy( + isTesting = false, + testResult = "SSL证书错误,HTTP地址请使用http://", + testSuccess = false + ) + } catch (e: Exception) { + _uiState.value = _uiState.value.copy( + isTesting = false, + testResult = "连接失败: ${e.javaClass.simpleName} - ${e.message ?: "未知错误"}", + testSuccess = false + ) + } + } + } + + class Factory( + private val sessionManager: SessionManager + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + return SettingsViewModel(sessionManager) as T + } + } +} diff --git a/app/src/main/java/com/example/ops_android/ui/warehouse/ItemDetailScreen.kt b/app/src/main/java/com/example/ops_android/ui/warehouse/ItemDetailScreen.kt new file mode 100644 index 0000000..e290404 --- /dev/null +++ b/app/src/main/java/com/example/ops_android/ui/warehouse/ItemDetailScreen.kt @@ -0,0 +1,70 @@ +package com.example.ops_android.ui.warehouse + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.compose.viewModel +import com.example.ops_android.data.model.Item +import com.example.ops_android.data.repository.WarehouseRepository +import com.example.ops_android.ui.LocalAppContainer +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +data class ItemDetailUiState(val item: Item? = null, val isLoading: Boolean = false, val errorMessage: String? = null) + +class ItemDetailViewModel(repo: WarehouseRepository, private val id: Int) : ViewModel() { + private val _s = MutableStateFlow(ItemDetailUiState()); val s: StateFlow = _s.asStateFlow() + private val repository = repo + init { load() } + private fun load() { viewModelScope.launch { _s.value = _s.value.copy(isLoading = true); repository.getItem(id).fold(onSuccess = { _s.value = _s.value.copy(item = it, isLoading = false) }, onFailure = { _s.value = _s.value.copy(isLoading = false, errorMessage = it.message) }) } } + class Factory(private val repo: WarehouseRepository, private val id: Int) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(c: Class): T = ItemDetailViewModel(repo, id) as T } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ItemDetailScreen(id: Int, onBack: () -> Unit) { + val vm: ItemDetailViewModel = viewModel(key = "item_$id", factory = ItemDetailViewModel.Factory(LocalAppContainer.current.warehouseRepository, id)) + val s by vm.s.collectAsState(); val item = s.item + + Scaffold(topBar = { TopAppBar(title = { Text("物品详情") }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, null, tint = Color.White) } }, colors = TopAppBarDefaults.topAppBarColors(containerColor = Color(0xFF667EEA), titleContentColor = Color.White)) }) { padding -> + if (s.isLoading) { Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { CircularProgressIndicator() }; return@Scaffold } + if (item == null) { Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { Text(s.errorMessage ?: "加载失败") }; return@Scaffold } + + LazyColumn(Modifier.fillMaxSize().padding(padding).padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + item { Text("基本信息", fontWeight = FontWeight.Bold, fontSize = 18.sp); Card(Modifier.fillMaxWidth()) { Column(Modifier.padding(16.dp)) { DetailRow("名称", item.name ?: "-"); DetailRow("编号", item.serial_number ?: "-"); DetailRow("规格", item.spec ?: "-"); DetailRow("条码", item.barcode ?: "-"); DetailRow("备注", item.remark ?: "-"); DetailRow("创建时间", item.created_at ?: "-") } } } + + item.move_history?.let { history -> + if (history.isNotEmpty()) { + item { Text("移动记录", fontWeight = FontWeight.Bold, fontSize = 16.sp) } + items(history) { h -> + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(12.dp)) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Text("${h.from_container ?: "-"} → ${h.to_container ?: "-"}", fontWeight = FontWeight.Medium); Text(h.moved_at ?: "", fontSize = 12.sp, color = Color.Gray) } + } + } + } + } + } + } + } +} + +@Composable +private fun DetailRow(label: String, value: String) { + Row(Modifier.fillMaxWidth().padding(vertical = 4.dp)) { Text(label, color = Color.Gray, modifier = Modifier.width(80.dp)); Text(value) } +} diff --git a/app/src/main/java/com/example/ops_android/ui/warehouse/ItemFormScreen.kt b/app/src/main/java/com/example/ops_android/ui/warehouse/ItemFormScreen.kt new file mode 100644 index 0000000..bbc8f5a --- /dev/null +++ b/app/src/main/java/com/example/ops_android/ui/warehouse/ItemFormScreen.kt @@ -0,0 +1,65 @@ +package com.example.ops_android.ui.warehouse + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.compose.viewModel +import com.example.ops_android.data.repository.WarehouseRepository +import com.example.ops_android.ui.LocalAppContainer +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +data class ItemFormUiState( + val name: String = "", val serialNumber: String = "", val spec: String = "", val remark: String = "", + val isSubmitting: Boolean = false, val errorMessage: String? = null, val success: Boolean = false +) + +class ItemFormViewModel(private val repo: WarehouseRepository, private val containerId: Int, private val editId: Int? = null) : ViewModel() { + private val _s = MutableStateFlow(ItemFormUiState()); val s: StateFlow = _s.asStateFlow() + fun onName(v: String) { _s.value = _s.value.copy(name = v) } + fun onSerial(v: String) { _s.value = _s.value.copy(serialNumber = v) } + fun onSpec(v: String) { _s.value = _s.value.copy(spec = v) } + fun onRemark(v: String) { _s.value = _s.value.copy(remark = v) } + fun submit() { + if (_s.value.name.isBlank()) { _s.value = _s.value.copy(errorMessage = "名称不能为空"); return } + val data = mapOf("name" to _s.value.name, "serial_number" to _s.value.serialNumber, "spec" to _s.value.spec, "remark" to _s.value.remark, "container_id" to containerId) + viewModelScope.launch { + _s.value = _s.value.copy(isSubmitting = true) + val r = if (editId != null) repo.updateItem(data + ("id" to editId)) else repo.addItem(data) + r.fold(onSuccess = { _s.value = _s.value.copy(isSubmitting = false, success = true) }, onFailure = { _s.value = _s.value.copy(isSubmitting = false, errorMessage = it.message) }) + } + } + class Factory(private val repo: WarehouseRepository, private val containerId: Int, private val editId: Int? = null) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(c: Class): T = ItemFormViewModel(repo, containerId, editId) as T } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ItemFormScreen(containerId: Int = 0, editId: Int? = null, onBack: () -> Unit, onSaved: () -> Unit) { + val vm: ItemFormViewModel = viewModel(factory = ItemFormViewModel.Factory(LocalAppContainer.current.warehouseRepository, containerId, editId)) + val s by vm.s.collectAsState() + LaunchedEffect(s.success) { if (s.success) onSaved() } + + Scaffold(topBar = { TopAppBar(title = { Text(if (editId != null) "编辑物品" else "添加物品") }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, null, tint = Color.White) } }, colors = TopAppBarDefaults.topAppBarColors(containerColor = Color(0xFF667EEA), titleContentColor = Color.White)) }) { padding -> + Column(Modifier.fillMaxSize().padding(padding).padding(16.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp)) { + OutlinedTextField(s.name, vm::onName, label = { Text("名称 *") }, modifier = Modifier.fillMaxWidth(), singleLine = true) + OutlinedTextField(s.serialNumber, vm::onSerial, label = { Text("编号") }, modifier = Modifier.fillMaxWidth(), singleLine = true) + OutlinedTextField(s.spec, vm::onSpec, label = { Text("规格") }, modifier = Modifier.fillMaxWidth(), singleLine = true) + OutlinedTextField(s.remark, vm::onRemark, label = { Text("备注") }, modifier = Modifier.fillMaxWidth(), minLines = 2) + s.errorMessage?.let { Text(it, color = Color.Red, fontSize = 14.sp) } + Button(onClick = { vm.submit() }, Modifier.fillMaxWidth().height(48.dp), enabled = !s.isSubmitting) { Text("提交") } + } + } +} diff --git a/app/src/main/java/com/example/ops_android/ui/warehouse/WarehouseScreen.kt b/app/src/main/java/com/example/ops_android/ui/warehouse/WarehouseScreen.kt new file mode 100644 index 0000000..d4072e5 --- /dev/null +++ b/app/src/main/java/com/example/ops_android/ui/warehouse/WarehouseScreen.kt @@ -0,0 +1,102 @@ +package com.example.ops_android.ui.warehouse + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.Inventory +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.example.ops_android.data.model.Container +import com.example.ops_android.data.model.Item +import com.example.ops_android.ui.LocalAppContainer + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun WarehouseScreen( + onItemClick: (Int) -> Unit, + viewModel: WarehouseViewModel = viewModel(factory = WarehouseViewModel.Factory(LocalAppContainer.current.warehouseRepository)) +) { + val s by viewModel.s.collectAsState() + var showAddDialog by remember { mutableStateOf(false) } + var newContainerName by remember { mutableStateOf("") } + var tabIndex by remember { mutableStateOf(0) } + + Scaffold( + topBar = { TopAppBar(title = { Text("仓库") }, colors = TopAppBarDefaults.topAppBarColors(containerColor = Color(0xFF667EEA), titleContentColor = Color.White)) }, + floatingActionButton = { FloatingActionButton(onClick = { showAddDialog = true }, containerColor = Color(0xFF667EEA)) { Icon(Icons.Default.Add, "新建容器", tint = Color.White) } } + ) { padding -> + Column(Modifier.padding(padding)) { + // Breadcrumb + ScrollableTabRow(selectedTabIndex = s.breadcrumb.size - 1) { + s.breadcrumb.forEachIndexed { idx, (id, name) -> + Tab(selected = idx == s.breadcrumb.size - 1, onClick = { viewModel.navigateToBreadcrumb(idx) }, text = { Text(name, fontSize = 12.sp) }) + } + } + + // Tabs: All / Items Only + Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilterChip(selected = !s.showItemsOnly, onClick = { viewModel.toggleItemsOnly() }, label = { Text("全部") }) + FilterChip(selected = s.showItemsOnly, onClick = { viewModel.toggleItemsOnly() }, label = { Text("仅物品") }) + } + + if (s.isLoading) Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() } + else LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + if (!s.showItemsOnly) { + items(s.containers) { container -> + ContainerItem(container) { viewModel.navigateTo(container.id, container.name ?: "未命名") } + } + } + items(s.items) { item -> + ItemRow(item) { onItemClick(item.id) } + } + } + } + } + + if (showAddDialog) AlertDialog( + onDismissRequest = { showAddDialog = false }, + title = { Text("新建容器") }, + text = { OutlinedTextField(newContainerName, { newContainerName = it }, label = { Text("容器名称") }, singleLine = true) }, + confirmButton = { Button(onClick = { if (newContainerName.isNotBlank()) { viewModel.addContainer(newContainerName); showAddDialog = false; newContainerName = "" } }) { Text("创建") } }, + dismissButton = { TextButton(onClick = { showAddDialog = false }) { Text("取消") } } + ) +} + +@Composable +private fun ContainerItem(container: Container, onClick: () -> Unit) { + Card(Modifier.fillMaxWidth().clickable { onClick() }) { + Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Default.Folder, null, tint = Color(0xFFFF9800)) + Spacer(Modifier.width(12.dp)) + Text(container.name ?: "未命名", modifier = Modifier.weight(1f), fontWeight = FontWeight.Medium) + Icon(Icons.Default.ChevronRight, null, tint = Color.Gray) + } + } +} + +@Composable +private fun ItemRow(item: Item, onClick: () -> Unit) { + Card(Modifier.fillMaxWidth().clickable { onClick() }) { + Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Default.Inventory, null, tint = Color(0xFF2196F3)) + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f)) { + Text(item.name ?: "未命名", fontWeight = FontWeight.Medium) + item.serial_number?.let { Text(it, fontSize = 12.sp, color = Color.Gray) } + } + Icon(Icons.Default.ChevronRight, null, tint = Color.Gray) + } + } +} diff --git a/app/src/main/java/com/example/ops_android/ui/warehouse/WarehouseViewModel.kt b/app/src/main/java/com/example/ops_android/ui/warehouse/WarehouseViewModel.kt new file mode 100644 index 0000000..a6c65cc --- /dev/null +++ b/app/src/main/java/com/example/ops_android/ui/warehouse/WarehouseViewModel.kt @@ -0,0 +1,74 @@ +package com.example.ops_android.ui.warehouse + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import com.example.ops_android.data.model.Container +import com.example.ops_android.data.model.Item +import com.example.ops_android.data.repository.WarehouseRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +data class WarehouseUiState( + val containers: List = emptyList(), + val items: List = emptyList(), + val breadcrumb: List> = listOf(0 to "根目录"), + val showItemsOnly: Boolean = false, + val isLoading: Boolean = false, + val errorMessage: String? = null +) + +class WarehouseViewModel( + private val repo: WarehouseRepository +) : ViewModel() { + private val _s = MutableStateFlow(WarehouseUiState()); val s: StateFlow = _s.asStateFlow() + + init { loadContainers(0) } + + fun loadContainers(parentId: Int) { + viewModelScope.launch { + _s.value = _s.value.copy(isLoading = true) + repo.listContainer(parentId).fold( + onSuccess = { _s.value = _s.value.copy(containers = it, isLoading = false) }, + onFailure = { _s.value = _s.value.copy(isLoading = false, errorMessage = it.message) } + ) + repo.listItem(parentId).fold( + onSuccess = { _s.value = _s.value.copy(items = it) }, + onFailure = { } + ) + } + } + + fun navigateTo(containerId: Int, containerName: String) { + val breadcrumb = _s.value.breadcrumb.toMutableList() + breadcrumb.add(containerId to containerName) + _s.value = _s.value.copy(breadcrumb = breadcrumb) + loadContainers(containerId) + } + + fun navigateToBreadcrumb(index: Int) { + val entry = _s.value.breadcrumb[index] + _s.value = _s.value.copy(breadcrumb = _s.value.breadcrumb.take(index + 1)) + loadContainers(entry.first) + } + + fun toggleItemsOnly() { _s.value = _s.value.copy(showItemsOnly = !_s.value.showItemsOnly) } + + fun refresh() { loadContainers(_s.value.breadcrumb.last().first) } + + fun addContainer(name: String) { + val parentId = _s.value.breadcrumb.last().first + viewModelScope.launch { + repo.addContainer(mapOf("name" to name, "parent_id" to parentId)).fold( + onSuccess = { refresh() }, + onFailure = { _s.value = _s.value.copy(errorMessage = it.message) } + ) + } + } + + class Factory(private val repo: WarehouseRepository) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") override fun create(c: Class): T = WarehouseViewModel(repo) as T + } +} diff --git a/app/src/main/java/com/example/ops_android/ui/workorder/WorkOrderDetailScreen.kt b/app/src/main/java/com/example/ops_android/ui/workorder/WorkOrderDetailScreen.kt new file mode 100644 index 0000000..5724b5e --- /dev/null +++ b/app/src/main/java/com/example/ops_android/ui/workorder/WorkOrderDetailScreen.kt @@ -0,0 +1,114 @@ +package com.example.ops_android.ui.workorder + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Print +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.compose.viewModel +import com.example.ops_android.data.model.Commit +import com.example.ops_android.data.model.WorkOrder +import com.example.ops_android.data.repository.WorkOrderRepository +import com.example.ops_android.ui.LocalAppContainer +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +data class WorkOrderDetailUiState( + val workOrder: WorkOrder? = null, val isLoading: Boolean = false, val errorMessage: String? = null, + val showCommitDialog: Boolean = false, val commitStatus: String = "", val commitComment: String = "", val isSubmitting: Boolean = false +) + +class WorkOrderDetailViewModel(repo: WorkOrderRepository, private val id: Int) : ViewModel() { + private val _s = MutableStateFlow(WorkOrderDetailUiState()); val s: StateFlow = _s.asStateFlow() + private val repository = repo + + init { load() } + fun load() { viewModelScope.launch { _s.value = _s.value.copy(isLoading = true); repository.get(id).fold(onSuccess = { _s.value = _s.value.copy(workOrder = it, isLoading = false) }, onFailure = { _s.value = _s.value.copy(isLoading = false, errorMessage = it.message) }) } } + fun showCommitDialog(status: String) { _s.value = _s.value.copy(showCommitDialog = true, commitStatus = status, commitComment = "") } + fun dismissCommitDialog() { _s.value = _s.value.copy(showCommitDialog = false) } + fun onCommentChange(v: String) { _s.value = _s.value.copy(commitComment = v) } + fun submitCommit() { viewModelScope.launch { + val st = _s.value; _s.value = st.copy(isSubmitting = true) + repository.commit(id, st.commitStatus.ifBlank { null }, st.commitComment.ifBlank { null }, null) + .fold(onSuccess = { _s.value = _s.value.copy(showCommitDialog = false, isSubmitting = false); load() }, onFailure = { _s.value = _s.value.copy(isSubmitting = false, errorMessage = it.message) }) + } } + + class Factory(private val repo: WorkOrderRepository, private val id: Int) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") override fun create(c: Class): T = WorkOrderDetailViewModel(repo, id) as T + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun WorkOrderDetailScreen(id: Int, onBack: () -> Unit) { + val vm: WorkOrderDetailViewModel = viewModel(key = "wo_$id", factory = WorkOrderDetailViewModel.Factory(LocalAppContainer.current.workOrderRepository, id)) + val s by vm.s.collectAsState(); val wo = s.workOrder + val sl = mapOf("pending" to "待处理", "checked" to "已检查", "parts_ordered" to "待配件", "repaired" to "已修复", "returned" to "已返还", "unrepairable" to "不可修") + + Scaffold(topBar = { + TopAppBar(title = { Text("工单详情") }, + navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, null, tint = Color.White) } }, + actions = { + val scope = rememberCoroutineScope() + wo?.let { wo_ -> + val printerManager = com.example.ops_android.ui.LocalAppContainer.current.printerManager + if (printerManager != null) { + IconButton(onClick = { + scope.launch { com.example.ops_android.printer.PrintHelper.printWorkOrderLabel(printerManager, wo_) } + }) { Icon(Icons.Default.Print, "打印", tint = Color.White) } + } + } + }, + colors = TopAppBarDefaults.topAppBarColors(containerColor = Color(0xFF667EEA), titleContentColor = Color.White)) + }) { padding -> + if (s.isLoading) { Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { CircularProgressIndicator() }; return@Scaffold } + if (wo == null) { Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { Text(s.errorMessage ?: "加载失败") }; return@Scaffold } + + LazyColumn(Modifier.fillMaxSize().padding(padding).padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + item { Text("基本信息", fontWeight = FontWeight.Bold, fontSize = 18.sp); Card(Modifier.fillMaxWidth()) { Column(Modifier.padding(16.dp)) { DetailRow("标题", wo.title ?: "-"); DetailRow("状态", sl[wo.status] ?: wo.status ?: "-"); DetailRow("描述", wo.description ?: "-"); DetailRow("创建时间", wo.created_at ?: "-") } } } + + wo.commits?.let { commits -> + if (commits.isNotEmpty()) { + item { Text("提交记录", fontWeight = FontWeight.Bold, fontSize = 16.sp) } + items(commits) { c: Commit -> + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(12.dp)) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Text(sl[c.status] ?: c.status ?: "", fontWeight = FontWeight.Medium, color = Color(0xFF667EEA)); Text(c.created_at ?: "", fontSize = 12.sp, color = Color.Gray) } + c.comment?.takeIf { it.isNotBlank() }?.let { Spacer(Modifier.height(4.dp)); Text(it, fontSize = 14.sp) } + } + } + } + } + } + + item { Spacer(Modifier.height(8.dp)); Text("流转状态", fontWeight = FontWeight.Bold, fontSize = 16.sp) + Row(Modifier.fillMaxWidth().wrapContentHeight(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + listOf("checked", "parts_ordered", "repaired", "returned", "unrepairable").forEach { st -> + if (st != wo.status) FilterChip(selected = false, onClick = { vm.showCommitDialog(st) }, label = { Text(sl[st] ?: st, fontSize = 12.sp) }) + } + } + } + } + } + + if (s.showCommitDialog) AlertDialog(onDismissRequest = { vm.dismissCommitDialog() }, title = { Text("流转为「${sl[s.commitStatus] ?: s.commitStatus}」") }, text = { Column { Text("备注(可选):"); Spacer(Modifier.height(8.dp)); OutlinedTextField(value = s.commitComment, onValueChange = vm::onCommentChange, Modifier.fillMaxWidth(), minLines = 2, placeholder = { Text("备注") }) } }, confirmButton = { Button(onClick = { vm.submitCommit() }, enabled = !s.isSubmitting) { Text("确认") } }, dismissButton = { TextButton(onClick = { vm.dismissCommitDialog() }) { Text("取消") } }) +} + +@Composable +private fun DetailRow(label: String, value: String) { + Row(Modifier.fillMaxWidth().padding(vertical = 4.dp)) { Text(label, color = Color.Gray, modifier = Modifier.width(80.dp)); Text(value) } +} diff --git a/app/src/main/java/com/example/ops_android/ui/workorder/WorkOrderFormScreen.kt b/app/src/main/java/com/example/ops_android/ui/workorder/WorkOrderFormScreen.kt new file mode 100644 index 0000000..af2a009 --- /dev/null +++ b/app/src/main/java/com/example/ops_android/ui/workorder/WorkOrderFormScreen.kt @@ -0,0 +1,64 @@ +package com.example.ops_android.ui.workorder + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.compose.viewModel +import com.example.ops_android.data.repository.WorkOrderRepository +import com.example.ops_android.ui.LocalAppContainer +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +data class WorkOrderFormUiState( + val title: String = "", val description: String = "", val isSubmitting: Boolean = false, val errorMessage: String? = null, val success: Boolean = false +) + +class WorkOrderFormViewModel(private val repo: WorkOrderRepository, private val editId: Int? = null) : ViewModel() { + private val _s = MutableStateFlow(WorkOrderFormUiState()); val s: StateFlow = _s.asStateFlow() + fun onTitle(v: String) { _s.value = _s.value.copy(title = v) } + fun onDesc(v: String) { _s.value = _s.value.copy(description = v) } + fun submit() { + if (_s.value.title.isBlank()) { _s.value = _s.value.copy(errorMessage = "标题不能为空"); return } + val data = mapOf("title" to _s.value.title, "description" to _s.value.description) + viewModelScope.launch { + _s.value = _s.value.copy(isSubmitting = true, errorMessage = null) + val r = if (editId != null) repo.update(data + ("id" to editId)) else repo.add(data) + r.fold(onSuccess = { _s.value = _s.value.copy(isSubmitting = false, success = true) }, onFailure = { _s.value = _s.value.copy(isSubmitting = false, errorMessage = it.message) }) + } + } + class Factory(private val repo: WorkOrderRepository, private val editId: Int? = null) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") override fun create(c: Class): T = WorkOrderFormViewModel(repo, editId) as T + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun WorkOrderFormScreen(editId: Int? = null, onBack: () -> Unit, onSaved: () -> Unit) { + val vm: WorkOrderFormViewModel = viewModel(factory = WorkOrderFormViewModel.Factory(LocalAppContainer.current.workOrderRepository, editId)) + val s by vm.s.collectAsState() + LaunchedEffect(s.success) { if (s.success) onSaved() } + + Scaffold(topBar = { TopAppBar(title = { Text(if (editId != null) "编辑工单" else "新建工单") }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, null, tint = Color.White) } }, colors = TopAppBarDefaults.topAppBarColors(containerColor = Color(0xFF667EEA), titleContentColor = Color.White)) }) { padding -> + Column(Modifier.fillMaxSize().padding(padding).padding(16.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp)) { + OutlinedTextField(s.title, vm::onTitle, label = { Text("标题 *") }, modifier = Modifier.fillMaxWidth(), singleLine = true) + OutlinedTextField(s.description, vm::onDesc, label = { Text("描述") }, modifier = Modifier.fillMaxWidth(), minLines = 3) + s.errorMessage?.let { Text(it, color = Color.Red, fontSize = 14.sp) } + Button(onClick = { vm.submit() }, Modifier.fillMaxWidth().height(48.dp), enabled = !s.isSubmitting) { + if (s.isSubmitting) CircularProgressIndicator(Modifier.size(20.dp), color = Color.White, strokeWidth = 2.dp) else Text("提交") + } + } + } +} diff --git a/app/src/main/java/com/example/ops_android/ui/workorder/WorkOrderListScreen.kt b/app/src/main/java/com/example/ops_android/ui/workorder/WorkOrderListScreen.kt new file mode 100644 index 0000000..be741e5 --- /dev/null +++ b/app/src/main/java/com/example/ops_android/ui/workorder/WorkOrderListScreen.kt @@ -0,0 +1,66 @@ +package com.example.ops_android.ui.workorder + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.example.ops_android.data.model.WorkOrder +import com.example.ops_android.ui.LocalAppContainer + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun WorkOrderListScreen( + onWorkOrderClick: (Int) -> Unit, + onAddClick: () -> Unit, + viewModel: WorkOrderListViewModel = viewModel(factory = WorkOrderListViewModel.Factory(LocalAppContainer.current.workOrderRepository)) +) { + val uiState by viewModel.uiState.collectAsState() + val statusColors = mapOf("pending" to Color(0xFFFF9800), "checked" to Color(0xFF2196F3), "parts_ordered" to Color(0xFF9C27B0), "repaired" to Color(0xFF4CAF50), "returned" to Color(0xFF4CAF50), "unrepairable" to Color(0xFFF44336)) + val statusLabels = mapOf("pending" to "待处理", "checked" to "已检查", "parts_ordered" to "待配件", "repaired" to "已修复", "returned" to "已返还", "unrepairable" to "不可修") + + Scaffold( + topBar = { + TopAppBar(title = { Text("工单") }, actions = { IconButton(onClick = onAddClick) { Icon(Icons.Default.Add, "新建", tint = Color.White) } }, + colors = TopAppBarDefaults.topAppBarColors(containerColor = Color(0xFF667EEA), titleContentColor = Color.White)) + }, + floatingActionButton = { FloatingActionButton(onClick = onAddClick, containerColor = Color(0xFF667EEA)) { Icon(Icons.Default.Add, "新建", tint = Color.White) } } + ) { padding -> + Column(Modifier.padding(padding)) { + ScrollableTabRow(selectedTabIndex = WO_STATUSES.indexOfFirst { it.first == uiState.selectedStatus }.coerceAtLeast(0), edgePadding = 8.dp) { + WO_STATUSES.forEachIndexed { i, (s, l) -> + Tab(selected = uiState.selectedStatus == s, onClick = { viewModel.selectStatus(s) }, text = { Text(l, fontSize = 12.sp, fontWeight = if (uiState.selectedStatus == s) FontWeight.Bold else FontWeight.Normal) }) + } + } + if (uiState.isLoading) Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() } + else LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + items(uiState.workOrders, key = { it.id }) { wo -> + Card(Modifier.fillMaxWidth().clickable { onWorkOrderClick(wo.id) }) { + Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text(wo.title ?: "无标题", fontWeight = FontWeight.Medium) + Spacer(Modifier.height(4.dp)) + Text(wo.description ?: "", fontSize = 12.sp, color = Color.Gray, maxLines = 1) + } + Surface(shape = MaterialTheme.shapes.small, color = statusColors[wo.status]?.copy(alpha = 0.15f) ?: Color.Gray) { + Text(statusLabels[wo.status] ?: wo.status ?: "", modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), fontSize = 11.sp, color = statusColors[wo.status] ?: Color.Gray) + } + } + } + } + if (uiState.isLoadingMore) item { Box(Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) { CircularProgressIndicator(Modifier.size(24.dp)) } } + item { LaunchedEffect(Unit) { viewModel.loadMore() } } + } + } + } +} diff --git a/app/src/main/java/com/example/ops_android/ui/workorder/WorkOrderListViewModel.kt b/app/src/main/java/com/example/ops_android/ui/workorder/WorkOrderListViewModel.kt new file mode 100644 index 0000000..0cdd12a --- /dev/null +++ b/app/src/main/java/com/example/ops_android/ui/workorder/WorkOrderListViewModel.kt @@ -0,0 +1,83 @@ +package com.example.ops_android.ui.workorder + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import com.example.ops_android.data.model.WorkOrder +import com.example.ops_android.data.repository.WorkOrderRepository +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +val WO_STATUSES = listOf( + "" to "全部", + "pending" to "待处理", + "checked" to "已检查", + "parts_ordered" to "待配件", + "repaired" to "已修复", + "returned" to "已返还", + "unrepairable" to "不可修" +) + +data class WorkOrderListUiState( + val workOrders: List = emptyList(), + val selectedStatus: String = "", + val isLoading: Boolean = false, + val isLoadingMore: Boolean = false, + val hasMore: Boolean = true, + val errorMessage: String? = null +) + +class WorkOrderListViewModel( + private val repository: WorkOrderRepository +) : ViewModel() { + private val _uiState = MutableStateFlow(WorkOrderListUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + private val pageSize = 20 + private var offset = 0 + + init { load() } + + fun selectStatus(s: String) { _uiState.value = _uiState.value.copy(selectedStatus = s); refresh() } + + fun refresh() { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true, workOrders = emptyList()) + offset = 0 + val r = repository.list(status = _uiState.value.selectedStatus.ifEmpty { null }) + r.fold( + onSuccess = { (list, _) -> _uiState.value = _uiState.value.copy(workOrders = list, isLoading = false, hasMore = list.size >= pageSize) }, + onFailure = { e -> _uiState.value = _uiState.value.copy(isLoading = false, errorMessage = e.message) } + ) + } + } + + fun loadMore() { + if (_uiState.value.isLoadingMore || !_uiState.value.hasMore) return + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoadingMore = true) + offset += pageSize + val r = repository.list(status = _uiState.value.selectedStatus.ifEmpty { null }, offset = offset, limit = pageSize) + r.fold( + onSuccess = { (list, _) -> _uiState.value = _uiState.value.copy(workOrders = _uiState.value.workOrders + list, isLoadingMore = false, hasMore = list.size >= pageSize) }, + onFailure = { _uiState.value = _uiState.value.copy(isLoadingMore = false) } + ) + } + } + + private fun load() { + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true) + repository.list().fold( + onSuccess = { (list, _) -> _uiState.value = _uiState.value.copy(workOrders = list, isLoading = false, hasMore = list.size >= pageSize) }, + onFailure = { e -> _uiState.value = _uiState.value.copy(isLoading = false, errorMessage = e.message) } + ) + } + } + + class Factory(private val repo: WorkOrderRepository) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = WorkOrderListViewModel(repo) as T + } +} diff --git a/app/version.properties b/app/version.properties index d3beaed..57f264d 100644 --- a/app/version.properties +++ b/app/version.properties @@ -1,3 +1,3 @@ -#Wed Jun 24 20:47:38 CST 2026 -VERSION_CODE=23 +#Wed Jun 24 22:02:35 CST 2026 +VERSION_CODE=45 VERSION_NAME=1.2 diff --git a/build.gradle.kts b/build.gradle.kts index 18318be..020183f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,5 +1,4 @@ -// Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.kotlin.compose) apply false -} \ No newline at end of file +} diff --git a/gradle.properties b/gradle.properties index 99fc155..f6e13a0 100644 --- a/gradle.properties +++ b/gradle.properties @@ -16,4 +16,4 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 # has changed. Additionally, Gradle applies performance optimizations to task execution. org.gradle.configuration-cache=true # Kotlin code style for this project: "official" or "obsolete": -kotlin.code.style=official \ No newline at end of file +kotlin.code.style=official diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 394ece7..10ace24 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,6 +8,15 @@ lifecycleRuntimeKtx = "2.6.1" activityCompose = "1.8.0" kotlin = "2.2.10" composeBom = "2026.02.01" +retrofit = "2.9.0" +okhttp = "4.12.0" +gson = "2.10.1" +navigationCompose = "2.7.7" +coil = "2.5.0" +datastore = "1.0.0" +camerax = "1.3.0" +mlkitBarcode = "17.2.0" +coroutines = "1.7.3" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -24,8 +33,25 @@ androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "u androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } +androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } +androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } +androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } + +retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } +retrofit-converter-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" } +okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } +okhttp-logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" } +gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" } + +coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" } + +camerax-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "camerax" } +camerax-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "camerax" } +camerax-view = { group = "androidx.camera", name = "camera-view", version.ref = "camerax" } +mlkit-barcode-scanning = { group = "com.google.mlkit", name = "barcode-scanning", version.ref = "mlkitBarcode" } + +kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } -