Compare commits

...
2 Commits
Author SHA1 Message Date
kevin 627099159f feat: 添加打印机黑标检测开关和走纸长度设置
- 支持黑标检测开关,关闭时用走纸长度替代

- 打印机设置页面:黑标开关、走纸长度输入、测试入口

- SessionManager 持久化打印机配置

- 新增 PrinterTestScreen 测试页面

- VERSION_CODE 82 -> 90
2026-06-26 15:43:57 +08:00
kevin c493dbc31c fix: 修复多个API通信和数据展示问题
- 修复仓库容器parent_id/物品container_id为null导致Gson反序列化失败
- 修复ScheduleApi参数名与后端不匹配
- 修复Profile页面username空字符串导致显示未登录
- 修复Retrofit Map类型参数编译后变为通配符被拒绝
- 修复ScrollableTabRow动态tab数量变化时IndexOutOfBounds崩溃
- 修复仓库新建容器字段名name→title匹配后端
- 修复新建物品传入错误的containerId
- 所有页面改为下拉刷新,去除首页自动刷新
- 新建工单支持关联物品/客户/图片上传
- 新增物品表单包含数量/关联客户/图片上传
- 物品详情页重构:基本信息/图片/关联工单/客户/移动历史/移动/删除/新建工单预填
- 打印模块添加黑标检测、Mutex并发锁、delay替换Thread.sleep
2026-06-25 15:43:56 +08:00
39 changed files with 1877 additions and 317 deletions
+10
View File
@@ -36,6 +36,16 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
@@ -20,6 +20,7 @@ class AppContainer(context: Context) {
val warehouseApi by lazy { apiServiceFactory.create<WarehouseApi>() }
val customerApi by lazy { apiServiceFactory.create<CustomerApi>() }
val scheduleApi by lazy { apiServiceFactory.create<ScheduleApi>() }
val fileApi by lazy { apiServiceFactory.create<FileApi>() }
val authRepository by lazy { AuthRepository(userApi, sessionManager) }
val orderRepository by lazy { OrderRepository(purchaseApi) }
@@ -5,6 +5,7 @@ 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.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.example.ops_android.data.model.Cookie
@@ -25,6 +26,8 @@ class SessionManager(private val context: Context) {
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")
private val KEY_PRINTER_BLACK_MARK = booleanPreferencesKey("printer_black_mark")
private val KEY_PRINTER_UNWIND_MM = intPreferencesKey("printer_unwind_mm")
}
@Volatile
@@ -35,6 +38,14 @@ class SessionManager(private val context: Context) {
var cachedCookieValue: String = ""
private set
@Volatile
var cachedBlackMarkEnabled: Boolean = true
private set
@Volatile
var cachedUnwindMm: Int = 40
private set
fun clearCookieCache() {
cachedCookieValue = ""
}
@@ -43,9 +54,11 @@ class SessionManager(private val context: Context) {
val cookieValue: Flow<String> = 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 ?: ""
val prefs = context.dataStore.data.first()
cachedBaseUrl = prefs[KEY_API_BASE_URL] ?: ""
cachedCookieValue = prefs[KEY_COOKIE_VALUE] ?: ""
cachedBlackMarkEnabled = prefs[KEY_PRINTER_BLACK_MARK] ?: true
cachedUnwindMm = prefs[KEY_PRINTER_UNWIND_MM] ?: 40
}
suspend fun getApiBaseUrl(): String = cachedBaseUrl.ifEmpty {
@@ -92,4 +105,14 @@ class SessionManager(private val context: Context) {
}
suspend fun logout() = clearCookie()
suspend fun setBlackMarkEnabled(enabled: Boolean) {
cachedBlackMarkEnabled = enabled
context.dataStore.edit { it[KEY_PRINTER_BLACK_MARK] = enabled }
}
suspend fun setUnwindMm(mm: Int) {
cachedUnwindMm = mm
context.dataStore.edit { it[KEY_PRINTER_UNWIND_MM] = mm }
}
}
@@ -5,7 +5,7 @@ import com.google.gson.annotations.SerializedName
data class Container(
@SerializedName("ID") val id: Int = 0,
@SerializedName("Title") val name: String? = null,
@SerializedName("ParentID") val parent_id: Int = 0,
@SerializedName("ParentID") val parent_id: Int? = null,
@SerializedName("Remark") val remark: String? = null,
@SerializedName("CreatedAt") val created_at: String? = null,
@SerializedName("CreatorID") val user_id: Int? = null,
@@ -8,8 +8,9 @@ data class Item(
@SerializedName("SerialNumber") val serial_number: String? = null,
@SerializedName("Remark") val remark: String? = null,
@SerializedName("Quantity") val quantity: Int = 1,
@SerializedName("ContainerID") val container_id: Int = 0,
@SerializedName("ContainerID") val container_id: Int? = null,
@SerializedName("CreatedAt") val created_at: String? = null,
@SerializedName("UpdatedAt") val updated_at: String? = null,
@SerializedName("CreatorID") val user_id: Int? = null,
@SerializedName("barcode") private val _barcode: String? = null,
@SerializedName("spec") private val _spec: String? = null,
@@ -17,6 +17,12 @@ class CookieInterceptor(
return chain.proceed(originalRequest)
}
// Skip multipart requests (e.g., file uploads)
val ct = originalRequest.body?.contentType()?.toString() ?: ""
if (ct.contains("multipart")) {
return chain.proceed(originalRequest)
}
val cookieValue = sessionManager.cachedCookieValue
val gson = Gson()
@@ -5,6 +5,12 @@ import com.example.ops_android.data.remote.ApiResponse
import retrofit2.http.Body
import retrofit2.http.POST
data class CustomerListRequest(
val search: String? = null,
val page: Int = 1,
val page_size: Int = 10
)
data class CustomerListResponse(
val customers: List<Customer>?
)
@@ -20,17 +26,17 @@ data class CustomerResponse(
interface CustomerApi {
@POST("/customer/list")
suspend fun list(@Body request: Map<String, Any> = emptyMap()): ApiResponse<CustomerListResponse>
suspend fun list(@Body request: CustomerListRequest): ApiResponse<CustomerListResponse>
@POST("/customer/get")
suspend fun get(@Body request: CustomerGetRequest): ApiResponse<CustomerResponse>
@POST("/customer/add")
suspend fun add(@Body request: Map<String, Any>): ApiResponse<Any>
suspend fun add(@Body request: Map<String, @JvmSuppressWildcards Any>): ApiResponse<Any>
@POST("/customer/update")
suspend fun update(@Body request: Map<String, Any>): ApiResponse<Any>
suspend fun update(@Body request: Map<String, @JvmSuppressWildcards Any>): ApiResponse<Any>
@POST("/customer/delete")
suspend fun delete(@Body request: Map<String, Int>): ApiResponse<Any>
suspend fun delete(@Body request: Map<String, @JvmSuppressWildcards Int>): ApiResponse<Any>
}
@@ -0,0 +1,24 @@
package com.example.ops_android.data.remote.api
import com.example.ops_android.data.remote.ApiResponse
import okhttp3.MultipartBody
import okhttp3.RequestBody
import retrofit2.http.Multipart
import retrofit2.http.POST
import retrofit2.http.Part
data class FileUploadResponse(
val hash: String? = null,
val get: String? = null,
val download: String? = null
)
interface FileApi {
@Multipart
@POST("/files/upload/image")
suspend fun uploadImage(
@Part("cookie") cookie: RequestBody,
@Part file: MultipartBody.Part
): ApiResponse<FileUploadResponse>
}
@@ -49,14 +49,14 @@ interface PurchaseApi {
suspend fun getOrder(@Body request: GetOrderRequest): ApiResponse<OrderDetailResponse>
@POST("/purchase/getordercount")
suspend fun getOrderCount(@Body request: Map<String, Any> = emptyMap()): ApiResponse<OrderCounts>
suspend fun getOrderCount(@Body request: EmptyBody = EmptyBody()): ApiResponse<OrderCounts>
@POST("/purchase/updatestatus")
suspend fun updateOrderStatus(@Body request: UpdateOrderStatusRequest): ApiResponse<Any>
@POST("/purchase/addorder")
suspend fun addOrder(@Body request: Map<String, Any>): ApiResponse<Any>
suspend fun addOrder(@Body request: Map<String, @JvmSuppressWildcards Any>): ApiResponse<Any>
@POST("/purchase/updateorder")
suspend fun updateOrder(@Body request: Map<String, Any>): ApiResponse<Any>
suspend fun updateOrder(@Body request: Map<String, @JvmSuppressWildcards Any>): ApiResponse<Any>
}
@@ -6,8 +6,8 @@ import retrofit2.http.Body
import retrofit2.http.POST
data class GetEventsRequest(
val start_date: String? = null,
val end_date: String? = null
val start: String? = null,
val end: String? = null
)
data class EventsResponse(
@@ -20,11 +20,11 @@ interface ScheduleApi {
suspend fun getEvents(@Body request: GetEventsRequest): ApiResponse<EventsResponse>
@POST("/schedule/addevent")
suspend fun addEvent(@Body request: Map<String, Any>): ApiResponse<Any>
suspend fun addEvent(@Body request: Map<String, @JvmSuppressWildcards Any>): ApiResponse<Any>
@POST("/schedule/editevent")
suspend fun editEvent(@Body request: Map<String, Any>): ApiResponse<Any>
suspend fun editEvent(@Body request: Map<String, @JvmSuppressWildcards Any>): ApiResponse<Any>
@POST("/schedule/deleevent")
suspend fun deleteEvent(@Body request: Map<String, Int>): ApiResponse<Any>
suspend fun deleteEvent(@Body request: Map<String, @JvmSuppressWildcards Int>): ApiResponse<Any>
}
@@ -43,6 +43,8 @@ data class UserInfoResponse(
val userInfo: UserDetail?
)
class EmptyBody
interface UserApi {
@POST("/users/login")
@@ -52,7 +54,7 @@ interface UserApi {
suspend fun register(@Body request: RegisterRequest): ApiResponse<Any>
@POST("/users/getinfo")
suspend fun getUserInfo(@Body request: Map<String, Any> = emptyMap()): ApiResponse<UserInfoResponse>
suspend fun getUserInfo(@Body request: EmptyBody = EmptyBody()): ApiResponse<UserInfoResponse>
@POST("/users/changePassword")
suspend fun changePassword(@Body request: ChangePasswordRequest): ApiResponse<Any>
@@ -2,12 +2,13 @@ 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.TabFileInfo
import com.example.ops_android.data.remote.ApiResponse
import retrofit2.http.Body
import retrofit2.http.POST
data class ListContainerRequest(
val parent_id: Int = 0
val parent_id: Int? = null
)
data class ListContainerResponse(
@@ -23,7 +24,10 @@ data class ContainerResponse(
)
data class ListItemRequest(
val container_id: Int = 0
val container_id: Int? = null,
val search: String? = null,
val entries: Int = 10,
val page: Int = 1
)
data class ListItemResponse(
@@ -35,12 +39,42 @@ data class GetItemRequest(
)
data class ItemResponse(
val item: Item?
val item: Item?,
val photos: List<TabFileInfo>? = null,
val commits: List<ItemCommitDetail>? = null,
val work_orders: List<LinkedWorkOrder>? = null,
val customers: List<LinkedCustomerInfo>? = null,
val canModifyItem: Boolean = false,
val container_breadcrumb: String? = null
)
data class ItemCommitDetail(
val ID: Int = 0,
val ItemID: Int = 0,
val UserID: Int = 0,
val OldContainerBreadcrumb: String? = null,
val NewContainerBreadcrumb: String? = null,
val Remark: String? = null,
val CreatedAt: String? = null
)
data class LinkedWorkOrder(
val id: Int = 0,
val title: String? = null,
val status: String? = null
)
data class LinkedCustomerInfo(
val id: Int = 0,
val first_name: String? = null,
val last_name: String? = null,
val title: String? = null,
val primary_phone: String? = null
)
data class MoveItemRequest(
val item_id: Int,
val new_container: Int
val new_container: Int? = null
)
data class WarehouseCountResponse(
@@ -58,13 +92,13 @@ interface WarehouseApi {
suspend fun getContainer(@Body request: GetContainerRequest): ApiResponse<ContainerResponse>
@POST("/warehouse/add_container")
suspend fun addContainer(@Body request: Map<String, Any>): ApiResponse<Any>
suspend fun addContainer(@Body request: Map<String, @JvmSuppressWildcards Any>): ApiResponse<Any>
@POST("/warehouse/update_container")
suspend fun updateContainer(@Body request: Map<String, Any>): ApiResponse<Any>
suspend fun updateContainer(@Body request: Map<String, @JvmSuppressWildcards Any>): ApiResponse<Any>
@POST("/warehouse/delete_container")
suspend fun deleteContainer(@Body request: Map<String, Int>): ApiResponse<Any>
suspend fun deleteContainer(@Body request: Map<String, @JvmSuppressWildcards Int>): ApiResponse<Any>
@POST("/warehouse/list_item")
suspend fun listItem(@Body request: ListItemRequest): ApiResponse<ListItemResponse>
@@ -73,17 +107,17 @@ interface WarehouseApi {
suspend fun getItem(@Body request: GetItemRequest): ApiResponse<ItemResponse>
@POST("/warehouse/add_item")
suspend fun addItem(@Body request: Map<String, Any>): ApiResponse<Any>
suspend fun addItem(@Body request: Map<String, @JvmSuppressWildcards Any>): ApiResponse<Any>
@POST("/warehouse/update_item")
suspend fun updateItem(@Body request: Map<String, Any>): ApiResponse<Any>
suspend fun updateItem(@Body request: Map<String, @JvmSuppressWildcards Any>): ApiResponse<Any>
@POST("/warehouse/delete_item")
suspend fun deleteItem(@Body request: Map<String, Int>): ApiResponse<Any>
suspend fun deleteItem(@Body request: Map<String, @JvmSuppressWildcards Int>): ApiResponse<Any>
@POST("/warehouse/move_item")
suspend fun moveItem(@Body request: MoveItemRequest): ApiResponse<Any>
@POST("/warehouse/count")
suspend fun getCount(@Body request: Map<String, Any> = emptyMap()): ApiResponse<WarehouseCountResponse>
suspend fun getCount(@Body request: EmptyBody = EmptyBody()): ApiResponse<WarehouseCountResponse>
}
@@ -75,16 +75,16 @@ interface WorkOrderApi {
suspend fun get(@Body request: WorkOrderGetRequest): ApiResponse<WorkOrderDetailResponse>
@POST("/work_order/count")
suspend fun getCount(@Body request: Map<String, Any> = emptyMap()): ApiResponse<WorkOrderCounts>
suspend fun getCount(@Body request: EmptyBody = EmptyBody()): ApiResponse<WorkOrderCounts>
@POST("/work_order/add")
suspend fun add(@Body request: Map<String, Any>): ApiResponse<Any>
suspend fun add(@Body request: Map<String, @JvmSuppressWildcards Any>): ApiResponse<Any>
@POST("/work_order/update")
suspend fun update(@Body request: Map<String, Any>): ApiResponse<Any>
suspend fun update(@Body request: Map<String, @JvmSuppressWildcards Any>): ApiResponse<Any>
@POST("/work_order/delete")
suspend fun delete(@Body request: Map<String, Int>): ApiResponse<Any>
suspend fun delete(@Body request: Map<String, @JvmSuppressWildcards Int>): ApiResponse<Any>
@POST("/work_order/commit")
suspend fun commit(@Body request: WorkOrderCommitRequest): ApiResponse<Any>
@@ -3,13 +3,14 @@ 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
import com.example.ops_android.data.remote.api.CustomerListRequest
class CustomerRepository(
private val customerApi: CustomerApi
) {
suspend fun list(): Result<List<Customer>> {
suspend fun list(search: String? = null): Result<List<Customer>> {
return try {
val response = customerApi.list()
val response = customerApi.list(CustomerListRequest(search = search, page_size = if (search != null) 10 else 5))
if (response.errCode == 0 && response.data != null) {
Result.success(response.data.customers ?: emptyList())
} else {
@@ -7,13 +7,14 @@ 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.ItemResponse
import com.example.ops_android.data.remote.api.MoveItemRequest
import com.example.ops_android.data.remote.api.WarehouseCountResponse
class WarehouseRepository(
private val warehouseApi: WarehouseApi
) {
suspend fun listContainer(parentId: Int = 0): Result<List<Container>> {
suspend fun listContainer(parentId: Int? = null): Result<List<Container>> {
return try {
val response = warehouseApi.listContainer(ListContainerRequest(parentId))
if (response.errCode == 0 && response.data != null) {
@@ -69,7 +70,7 @@ class WarehouseRepository(
}
}
suspend fun listItem(containerId: Int = 0): Result<List<Item>> {
suspend fun listItem(containerId: Int? = null): Result<List<Item>> {
return try {
val response = warehouseApi.listItem(ListItemRequest(containerId))
if (response.errCode == 0 && response.data != null) {
@@ -82,11 +83,25 @@ class WarehouseRepository(
}
}
suspend fun getItem(id: Int): Result<Item> {
suspend fun searchItems(query: String): Result<List<Item>> {
return try {
val request = ListItemRequest(search = query, entries = 10)
val response = warehouseApi.listItem(request)
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<ItemResponse> {
return try {
val response = warehouseApi.getItem(GetItemRequest(id))
if (response.errCode == 0 && response.data?.item != null) {
Result.success(response.data.item)
if (response.errCode == 0 && response.data != null) {
Result.success(response.data)
} else {
Result.failure(Exception("获取物品详情失败"))
}
@@ -125,7 +140,7 @@ class WarehouseRepository(
}
}
suspend fun moveItem(itemId: Int, newContainer: Int): Result<Unit> {
suspend fun moveItem(itemId: Int, newContainer: Int?): Result<Unit> {
return try {
val response = warehouseApi.moveItem(MoveItemRequest(itemId, newContainer))
if (response.errCode == 0) Result.success(Unit)
@@ -4,134 +4,162 @@ 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.delay
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
object PrintHelper {
private val printMutex = Mutex()
suspend fun printOrderLabel(
printerManager: PrinterManager,
order: PurchaseOrder
): Result<Unit> = 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 = {}
order: PurchaseOrder,
blackMarkEnabled: Boolean = true,
unwindMm: Int = 40
): Result<Unit> = printMutex.withLock {
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}")
}
if (blackMarkEnabled) {
printerManager.addLineFeed(2)
printerManager.enableMarkDetection(true)
} else {
printerManager.addLineFeed((unwindMm / 7).toInt().coerceAtLeast(2))
}
printerManager.commit().fold(
onSuccess = {
delay(1000)
printerManager.closeDevice()
Result.success(Unit)
},
onFailure = { Result.failure(it) }
)
} catch (e: Exception) {
Result.failure(e)
}
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<Unit> = 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 = {}
workOrder: WorkOrder,
blackMarkEnabled: Boolean = true,
unwindMm: Int = 40
): Result<Unit> = printMutex.withLock {
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}")
}
if (blackMarkEnabled) {
printerManager.addLineFeed(2)
printerManager.enableMarkDetection(true)
} else {
printerManager.addLineFeed((unwindMm / 7).toInt().coerceAtLeast(2))
}
printerManager.commit().fold(
onSuccess = {
delay(1000)
printerManager.closeDevice()
Result.success(Unit)
},
onFailure = { Result.failure(it) }
)
} catch (e: Exception) {
Result.failure(e)
}
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<Unit> = 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 = {}
item: Item,
blackMarkEnabled: Boolean = true,
unwindMm: Int = 40
): Result<Unit> = printMutex.withLock {
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") }
if (item.id > 0) {
printerManager.addBarcode("ITEM-${item.id}")
}
if (blackMarkEnabled) {
printerManager.addLineFeed(2)
printerManager.enableMarkDetection(true)
} else {
printerManager.addLineFeed((unwindMm / 7).toInt().coerceAtLeast(2))
}
printerManager.commit().fold(
onSuccess = {
delay(1000)
printerManager.closeDevice()
Result.success(Unit)
},
onFailure = { Result.failure(it) }
)
} catch (e: Exception) {
Result.failure(e)
}
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<Unit> = 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 = {}
barcode: String? = null,
blackMarkEnabled: Boolean = true,
unwindMm: Int = 40
): Result<Unit> = printMutex.withLock {
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)
}
if (blackMarkEnabled) {
printerManager.addLineFeed(2)
printerManager.enableMarkDetection(true)
} else {
printerManager.addLineFeed((unwindMm / 7).toInt().coerceAtLeast(2))
}
printerManager.commit().fold(
onSuccess = {
delay(1000)
printerManager.closeDevice()
Result.success(Unit)
},
onFailure = { Result.failure(it) }
)
} catch (e: Exception) {
Result.failure(e)
}
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)
}
}
}
@@ -255,6 +255,20 @@ class PrinterManager(private val context: Context) {
printManager!!.setUnwindPaperLen(len)
}
fun getUnwindPaperLenInt(): Int? = try {
printManager?.unwindPaperLen ?: printManager?.getUnwindPaperLen()
} catch (e: Exception) {
Log.e(TAG, "getUnwindPaperLen failed", e)
null
}
fun getFeedPaperSpaceInt(): Int? = try {
printManager?.feedPaperSpace ?: printManager?.getFeedPaperSpace()
} catch (e: Exception) {
Log.e(TAG, "getFeedPaperSpace failed", e)
null
}
// ══════════════════════════════════════════════════════════
// 黑标 / 标签
// ══════════════════════════════════════════════════════════
@@ -8,6 +8,7 @@ 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.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -43,13 +44,19 @@ fun HomeScreen(
)
}
) { padding ->
LazyColumn(
PullToRefreshBox(
isRefreshing = uiState.isRefreshing,
onRefresh = { viewModel.refresh() },
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
if (uiState.isLoading) {
item {
Box(
@@ -181,6 +188,7 @@ fun HomeScreen(
}
}
}
}
}
}
@@ -9,12 +9,9 @@ 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.*
@@ -24,6 +21,7 @@ data class HomeUiState(
val orderCounts: OrderCounts? = null,
val workOrderCounts: WorkOrderCounts? = null,
val isLoading: Boolean = false,
val isRefreshing: Boolean = false,
val errorMessage: String? = null
)
@@ -36,11 +34,8 @@ class HomeViewModel(
private val _uiState = MutableStateFlow(HomeUiState())
val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow()
private var autoRefreshJob: Job? = null
init {
loadData()
startAutoRefresh()
}
fun loadData() {
@@ -67,18 +62,21 @@ class HomeViewModel(
}
}
private fun startAutoRefresh() {
autoRefreshJob = viewModelScope.launch {
while (isActive) {
delay(5000)
loadData()
}
}
}
fun refresh() {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isRefreshing = true)
val today = SimpleDateFormat("yyyy-MM-dd", Locale.US).format(Date())
val eventsResult = scheduleRepository.getEvents(today, today)
val orderCounts = orderRepository.getOrderCount()
val workOrderCounts = workOrderRepository.getCount()
override fun onCleared() {
super.onCleared()
autoRefreshJob?.cancel()
_uiState.value = _uiState.value.copy(
todayEvents = eventsResult.getOrNull() ?: emptyList(),
orderCounts = orderCounts.getOrNull(),
workOrderCounts = workOrderCounts.getOrNull(),
isRefreshing = false
)
}
}
class Factory(
@@ -29,6 +29,7 @@ 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.settings.PrinterTestScreen
import com.example.ops_android.ui.warehouse.ItemDetailScreen
import com.example.ops_android.ui.warehouse.ItemFormScreen
import com.example.ops_android.ui.warehouse.WarehouseScreen
@@ -45,15 +46,17 @@ object Routes {
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 WORK_ORDER_FORM = "work_order_form?editId={editId}&prefillItemId={prefillItemId}"
const val ITEM_DETAIL = "item_detail/{id}"
const val ITEM_FORM = "item_form/{containerId}?editId={editId}"
const val CUSTOMER_LIST = "customer_list"
const val PRINTER_TEST = "printer_test"
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 workOrderForm(editId: Int? = null, prefillItemId: Int = -1) =
"work_order_form?editId=${editId ?: -1}&prefillItemId=$prefillItemId"
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"
}
@@ -94,7 +97,13 @@ fun OPSNavGraph(isLoggedIn: Boolean) {
)
}
composable(Routes.SETTINGS) {
SettingsScreen(onBack = { rootNavController.popBackStack() })
SettingsScreen(
onBack = { rootNavController.popBackStack() },
onPrinterTest = { rootNavController.navigate(Routes.PRINTER_TEST) }
)
}
composable(Routes.PRINTER_TEST) {
PrinterTestScreen(onBack = { rootNavController.popBackStack() })
}
composable(Routes.SEARCH) {
SearchScreen(
@@ -120,13 +129,19 @@ fun OPSNavGraph(isLoggedIn: Boolean) {
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 ->
composable(Routes.WORK_ORDER_FORM, arguments = listOf(
navArgument("editId") { type = NavType.IntType; defaultValue = -1 },
navArgument("prefillItemId") { type = NavType.IntType; defaultValue = -1 }
)) { backStackEntry ->
val editId = backStackEntry.arguments?.getInt("editId")?.takeIf { it > 0 }
WorkOrderFormScreen(editId = editId, onBack = { rootNavController.popBackStack() }, onSaved = { rootNavController.popBackStack() })
val prefillItemId = backStackEntry.arguments?.getInt("prefillItemId")?.takeIf { it > 0 }
WorkOrderFormScreen(editId = editId, prefillItemId = prefillItemId, 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() })
ItemDetailScreen(id = id, onBack = { rootNavController.popBackStack() },
onCreateWorkOrder = { itemId -> rootNavController.navigate(Routes.workOrderForm(prefillItemId = itemId)) },
onWorkOrderClick = { woId -> rootNavController.navigate(Routes.workOrderDetail(woId)) })
}
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
@@ -186,7 +201,8 @@ fun MainScreen(onLogout: () -> Unit, onNavigate: (String) -> Unit) {
}
composable(BottomTabs.WAREHOUSE) {
WarehouseScreen(
onItemClick = { id -> onNavigate(Routes.itemDetail(id)) }
onItemClick = { id -> onNavigate(Routes.itemDetail(id)) },
onAddItemClick = { containerId -> onNavigate(Routes.itemForm(containerId)) }
)
}
composable(BottomTabs.PROFILE) {
@@ -58,9 +58,10 @@ fun OrderDetailScreen(
order?.let { o ->
val printerManager = LocalAppContainer.current.printerManager
if (printerManager != null) {
val sm = LocalAppContainer.current.sessionManager
IconButton(onClick = {
scope.launch {
com.example.ops_android.printer.PrintHelper.printOrderLabel(printerManager, o)
com.example.ops_android.printer.PrintHelper.printOrderLabel(printerManager, o, sm.cachedBlackMarkEnabled, sm.cachedUnwindMm)
}
}) {
Icon(Icons.Default.Print, "打印", tint = Color.White)
@@ -7,6 +7,7 @@ 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.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -53,7 +54,7 @@ fun OrderListScreen(
) { padding ->
Column(modifier = Modifier.padding(padding)) {
// Status filter tabs
ScrollableTabRow(
PrimaryScrollableTabRow(
selectedTabIndex = ORDER_STATUSES.indexOfFirst { it.first == uiState.selectedStatus }.coerceAtLeast(0),
modifier = Modifier.fillMaxWidth(),
edgePadding = 8.dp
@@ -89,29 +90,35 @@ fun OrderListScreen(
}
}
else -> {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
PullToRefreshBox(
isRefreshing = uiState.isRefreshing,
onRefresh = { viewModel.refresh() },
modifier = Modifier.fillMaxSize()
) {
items(uiState.orders, key = { it.id }) { order ->
OrderListItem(
order = order,
onClick = { onOrderClick(order.id) }
)
}
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))
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() }
// infinite scroll trigger
item {
LaunchedEffect(Unit) { viewModel.loadMore() }
}
}
}
}
@@ -10,6 +10,7 @@ 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.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -35,6 +36,10 @@ fun ProfileScreen(
val uiState by viewModel.uiState.collectAsState()
var showLogoutDialog by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
viewModel.loadProfile()
}
LaunchedEffect(uiState.isLoggedOut) {
if (uiState.isLoggedOut) {
onLogout()
@@ -52,12 +57,35 @@ fun ProfileScreen(
)
}
) { padding ->
Column(
PullToRefreshBox(
isRefreshing = uiState.isRefreshing,
onRefresh = { viewModel.refresh() },
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
) {
if (uiState.isLoading) {
Box(
modifier = Modifier.fillMaxWidth().padding(32.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
uiState.errorMessage?.let { error ->
Card(
modifier = Modifier.fillMaxWidth().padding(16.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xFFFFEBEE))
) {
Text(text = error, modifier = Modifier.padding(16.dp), color = Color(0xFFC62828))
}
}
Box(
modifier = Modifier
.fillMaxWidth()
@@ -129,6 +157,7 @@ fun ProfileScreen(
)
}
}
}
if (showLogoutDialog) {
AlertDialog(
@@ -16,6 +16,7 @@ data class ProfileUiState(
val user: UserInfo? = null,
val userInfo: UserDetail? = null,
val isLoading: Boolean = false,
val isRefreshing: Boolean = false,
val errorMessage: String? = null,
val isLoggedOut: Boolean = false
)
@@ -55,6 +56,28 @@ class ProfileViewModel(
}
}
fun refresh() {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isRefreshing = true)
authRepository.getUserInfo().fold(
onSuccess = { (user, userInfo) ->
_uiState.value = _uiState.value.copy(
user = user,
userInfo = userInfo,
isRefreshing = false,
errorMessage = null
)
},
onFailure = { e ->
_uiState.value = _uiState.value.copy(
isRefreshing = false,
errorMessage = e.message ?: "加载失败"
)
}
)
}
}
fun logout() {
viewModelScope.launch {
authRepository.logout()
@@ -66,7 +89,7 @@ class ProfileViewModel(
get() {
val info = _uiState.value.userInfo
val user = _uiState.value.user
return info?.Username ?: user?.Name ?: ""
return info?.Username?.ifBlank { null } ?: user?.Name ?: ""
}
val avatarPath: String?
@@ -138,7 +138,7 @@ fun SearchScreen(
)
// Category tabs
ScrollableTabRow(
PrimaryScrollableTabRow(
selectedTabIndex = SearchCategory.entries.indexOf(uiState.category),
modifier = Modifier.fillMaxWidth(),
edgePadding = 8.dp
@@ -0,0 +1,225 @@
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 com.example.ops_android.ui.LocalAppContainer
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PrinterTestScreen(onBack: () -> Unit) {
val appContainer = LocalAppContainer.current
val printerManager = appContainer.printerManager
val sessionManager = appContainer.sessionManager
val scope = rememberCoroutineScope()
var isTesting by remember { mutableStateOf(false) }
var testResult by remember { mutableStateOf<String?>(null) }
var testSuccess by remember { mutableStateOf<Boolean?>(null) }
val isConnected = remember { derivedStateOf { printerManager?.isConnected() == true } }
val firmware = remember { derivedStateOf { printerManager?.getFirmwareVersion() } }
val unwindMm = remember { derivedStateOf { sessionManager.cachedUnwindMm } }
val blackMarkEnabled = remember { derivedStateOf { sessionManager.cachedBlackMarkEnabled } }
fun runTest(label: String, action: suspend () -> Unit) {
val pm = printerManager ?: return
isTesting = true
testResult = null
testSuccess = null
scope.launch {
withContext(Dispatchers.IO) {
try {
action()
testSuccess = true
testResult = "[$label] 执行完成"
} catch (e: Exception) {
testSuccess = false
testResult = "[$label] 失败: ${e.message}"
} finally {
isTesting = false
}
}
}
}
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(12.dp)
) {
Card {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text("连接状态", color = Color.Gray)
Text(
if (isConnected.value) "已连接" else "未连接",
color = if (isConnected.value) Color(0xFF4CAF50) else Color(0xFFF44336)
)
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text("固件版本", color = Color.Gray)
Text(firmware.value ?: "未知")
}
}
}
Card {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text("黑标检测", color = Color.Gray)
Text(if (blackMarkEnabled.value) "启用" else "禁用")
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text("走纸长度", color = Color.Gray)
Text("${unwindMm.value} mm")
}
}
}
HorizontalDivider()
Text("走纸测试", style = MaterialTheme.typography.titleMedium)
Button(
onClick = {
runTest("空走纸") {
val pm = printerManager!!
pm.initDevice().getOrThrow()
pm.commit().getOrThrow()
delay(1000)
pm.closeDevice().getOrThrow()
}
},
modifier = Modifier.fillMaxWidth(),
enabled = isConnected.value && !isTesting
) {
Text("测试A:空走纸(无内容,看默认基线)")
}
Button(
onClick = {
runTest("空走纸 + 5行换行") {
val pm = printerManager!!
pm.initDevice().getOrThrow()
pm.addLineFeed(5)
pm.commit().getOrThrow()
delay(1000)
pm.closeDevice().getOrThrow()
}
},
modifier = Modifier.fillMaxWidth(),
enabled = isConnected.value && !isTesting,
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF5C6BC0))
) {
Text("测试B:空走纸 + 5行换行")
}
Button(
onClick = {
runTest("空走纸 + 10行换行") {
val pm = printerManager!!
pm.initDevice().getOrThrow()
pm.addLineFeed(10)
pm.commit().getOrThrow()
delay(1000)
pm.closeDevice().getOrThrow()
}
},
modifier = Modifier.fillMaxWidth(),
enabled = isConnected.value && !isTesting,
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF8D6E63))
) {
Text("测试C:空走纸 + 10行换行")
}
Text(
"目标:对比B(5行)和C(10行)的出纸长度,算出每行=多少mm,填入代码中校准",
fontSize = 13.sp,
color = Color.Gray
)
HorizontalDivider()
Text("SDK调试", style = MaterialTheme.typography.titleMedium)
Button(
onClick = {
val pm = printerManager
if (pm != null) {
val unwindVal = pm.getUnwindPaperLenInt()
val feedSpaceVal = pm.getFeedPaperSpaceInt()
testSuccess = null
testResult = "getUnwindPaperLen = $unwindVal, getFeedPaperSpace = $feedSpaceVal"
}
},
modifier = Modifier.fillMaxWidth(),
enabled = isConnected.value && !isTesting
) {
Text("读取 SDK 内部走纸参数")
}
if (!isConnected.value && printerManager != null) {
Button(
onClick = { printerManager?.bindService() },
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondary)
) {
Text("绑定打印服务")
}
}
testResult?.let {
Card(
colors = CardDefaults.cardColors(
containerColor = if (testSuccess == true) Color(0xFFE8F5E9) else if (testSuccess == false) Color(0xFFFFEBEE) else Color(0xFFF5F5F5)
)
) {
Text(
text = it,
modifier = Modifier.padding(12.dp),
color = when (testSuccess) {
true -> Color(0xFF2E7D32)
false -> Color(0xFFC62828)
else -> Color.Gray
},
fontSize = 14.sp
)
}
}
}
}
}
@@ -20,6 +20,7 @@ import com.example.ops_android.ui.LocalAppContainer
@Composable
fun SettingsScreen(
onBack: () -> Unit,
onPrinterTest: () -> Unit = {},
viewModel: SettingsViewModel = viewModel(factory = SettingsViewModel.Factory(LocalAppContainer.current.sessionManager))
) {
val uiState by viewModel.uiState.collectAsState()
@@ -121,6 +122,32 @@ fun SettingsScreen(
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
Text("打印机设置", style = MaterialTheme.typography.titleMedium)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
Text("黑标检测")
Switch(checked = uiState.blackMarkEnabled, onCheckedChange = viewModel::onBlackMarkChange)
}
if (!uiState.blackMarkEnabled) {
OutlinedTextField(
value = uiState.unwindMm,
onValueChange = viewModel::onUnwindMmChange,
label = { Text("单次走纸长度 (mm)") },
suffix = { Text("mm") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Button(onClick = { viewModel.saveUnwindMm() }, modifier = Modifier.fillMaxWidth()) {
Text("保存走纸长度")
}
Button(onClick = onPrinterTest, modifier = Modifier.fillMaxWidth(), colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondary)) {
Text("测试打印机")
}
}
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
Text(
text = "关于",
style = MaterialTheme.typography.titleMedium
@@ -23,7 +23,9 @@ data class SettingsUiState(
val isTesting: Boolean = false,
val testResult: String? = null,
val testSuccess: Boolean = false,
val message: String? = null
val message: String? = null,
val blackMarkEnabled: Boolean = true,
val unwindMm: String = "40"
)
class SettingsViewModel(
@@ -41,6 +43,7 @@ class SettingsViewModel(
init {
loadCurrentUrl()
loadPrinterSettings()
}
private fun loadCurrentUrl() {
@@ -50,10 +53,36 @@ class SettingsViewModel(
}
}
private fun loadPrinterSettings() {
_uiState.value = _uiState.value.copy(
blackMarkEnabled = sessionManager.cachedBlackMarkEnabled,
unwindMm = sessionManager.cachedUnwindMm.toString()
)
}
fun onUrlChange(value: String) {
_uiState.value = _uiState.value.copy(apiBaseUrl = value, message = null)
}
fun onBlackMarkChange(enabled: Boolean) {
_uiState.value = _uiState.value.copy(blackMarkEnabled = enabled)
viewModelScope.launch { sessionManager.setBlackMarkEnabled(enabled) }
}
fun onUnwindMmChange(value: String) {
if (value.isEmpty() || value.all { it.isDigit() } && value.toIntOrNull()?.let { it in 1..200 } == true) {
_uiState.value = _uiState.value.copy(unwindMm = value)
}
}
fun saveUnwindMm() {
val mm = _uiState.value.unwindMm.toIntOrNull() ?: 40
viewModelScope.launch {
sessionManager.setUnwindMm(mm)
_uiState.value = _uiState.value.copy(message = "走纸长度已保存")
}
}
fun saveUrl() {
viewModelScope.launch {
val url = _uiState.value.apiBaseUrl.trim()
@@ -1,23 +1,32 @@
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.automirrored.filled.ArrowBack
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.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
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 coil.compose.AsyncImage
import com.example.ops_android.data.model.TabFileInfo
import com.example.ops_android.data.remote.api.ItemCommitDetail
import com.example.ops_android.data.remote.api.ItemResponse
import com.example.ops_android.data.remote.api.LinkedCustomerInfo
import com.example.ops_android.data.remote.api.LinkedWorkOrder
import com.example.ops_android.data.repository.WarehouseRepository
import com.example.ops_android.ui.LocalAppContainer
import kotlinx.coroutines.flow.MutableStateFlow
@@ -25,36 +34,203 @@ 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)
data class ItemDetailUiState(
val itemResponse: ItemResponse? = null,
val isLoading: Boolean = false,
val isDeleting: Boolean = false,
val errorMessage: String? = null,
val deleted: Boolean = false
)
class ItemDetailViewModel(
private val repo: WarehouseRepository,
private val baseUrl: String,
private val id: Int
) : ViewModel() {
private val _s = MutableStateFlow(ItemDetailUiState())
val s: StateFlow<ItemDetailUiState> = _s.asStateFlow()
class ItemDetailViewModel(repo: WarehouseRepository, private val id: Int) : ViewModel() {
private val _s = MutableStateFlow(ItemDetailUiState()); val s: StateFlow<ItemDetailUiState> = _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 <T : ViewModel> create(c: Class<T>): T = ItemDetailViewModel(repo, id) as T }
fun load() {
viewModelScope.launch {
_s.value = _s.value.copy(isLoading = true)
repo.getItem(id).fold(
onSuccess = { _s.value = _s.value.copy(itemResponse = it, isLoading = false) },
onFailure = { _s.value = _s.value.copy(isLoading = false, errorMessage = it.message) }
)
}
}
fun delete() {
viewModelScope.launch {
_s.value = _s.value.copy(isDeleting = true)
repo.deleteItem(id).fold(
onSuccess = { _s.value = _s.value.copy(isDeleting = false, deleted = true) },
onFailure = { _s.value = _s.value.copy(isDeleting = false, errorMessage = it.message) }
)
}
}
fun move(newContainerId: Int?) {
viewModelScope.launch {
_s.value = _s.value.copy(isLoading = true)
val targetId = newContainerId ?: 0 // 0 means unstored
repo.moveItem(id, targetId).fold(
onSuccess = { load() },
onFailure = { _s.value = _s.value.copy(isLoading = false, errorMessage = it.message) }
)
}
}
fun photoUrl(hash: String?): String {
if (hash.isNullOrBlank()) return ""
val url = baseUrl.trimEnd('/')
return "$url/files/get/$hash"
}
class Factory(
private val repo: WarehouseRepository,
private val baseUrl: String,
private val id: Int
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(c: Class<T>): T = ItemDetailViewModel(repo, baseUrl, 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
fun ItemDetailScreen(id: Int, onBack: () -> Unit, onCreateWorkOrder: (Int) -> Unit = {}, onWorkOrderClick: (Int) -> Unit = {}) {
val app = LocalAppContainer.current
val vm: ItemDetailViewModel = viewModel(
key = "item_detail_$id",
factory = ItemDetailViewModel.Factory(app.warehouseRepository, app.sessionManager.cachedBaseUrl, id)
)
val s by vm.s.collectAsState()
val resp = s.itemResponse
val item = resp?.item
var tabIndex by remember { mutableIntStateOf(0) }
var showDeleteDialog by remember { mutableStateOf(false) }
var showMoveDialog by remember { mutableStateOf(false) }
var previewPhotoUrl by remember { mutableStateOf<String?>(null) }
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 ->
LaunchedEffect(s.deleted) { if (s.deleted) onBack() }
Scaffold(
topBar = {
TopAppBar(
title = { Text("物品详情") },
navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, null, tint = Color.White) } },
actions = {
IconButton(onClick = { onCreateWorkOrder(id) }) { Icon(Icons.Default.AddTask, "新建工单", tint = Color.White) }
if (resp?.canModifyItem == true) {
IconButton(onClick = { showMoveDialog = true }) { Icon(Icons.Default.OpenWith, "移动", tint = Color.White) }
IconButton(onClick = { showDeleteDialog = true }) { Icon(Icons.Default.Delete, "删除", 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 }
if (item == null) { Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { Text(s.errorMessage ?: "加载失败", color = Color.Red) }; 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 ?: "-") } } }
Column(Modifier.fillMaxSize().padding(padding)) {
LazyColumn(Modifier.weight(1f).padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
// Basic info
item {
Text(item.name ?: "未命名", fontWeight = FontWeight.Bold, fontSize = 20.sp)
item.serial_number?.let { Text(it, fontSize = 14.sp, color = Color.Gray) }
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
Text("数量: ${item.quantity}", fontSize = 13.sp, color = Color.Gray)
resp.container_breadcrumb?.let { breadcrumb ->
if (breadcrumb.isNotBlank()) {
Text("位置: $breadcrumb", fontSize = 13.sp, color = Color(0xFF667EEA))
}
}
}
}
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) }
// Photos
val photos = resp.photos
if (!photos.isNullOrEmpty()) {
item {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
photos.forEach { photo ->
val url = vm.photoUrl(photo.sha256)
AsyncImage(
model = url, contentDescription = null,
modifier = Modifier.size(100.dp).clickable { previewPhotoUrl = url },
contentScale = ContentScale.Crop
)
}
}
}
}
// Remark
item.remark?.let { remark ->
if (remark.isNotBlank()) {
item { Text("备注: $remark", fontSize = 13.sp, color = Color.Gray) }
}
}
// Dates
item {
Row(horizontalArrangement = Arrangement.spacedBy(24.dp)) {
item.created_at?.let { Text("创建: $it", fontSize = 12.sp, color = Color.Gray) }
item.updated_at?.let { Text("更新: $it", fontSize = 12.sp, color = Color.Gray) }
}
}
// Tabs
item {
TabRow(selectedTabIndex = tabIndex) {
Tab(tabIndex == 0, { tabIndex = 0 }) { Text("关联工单") }
Tab(tabIndex == 1, { tabIndex = 1 }) { Text("关联客户") }
Tab(tabIndex == 2, { tabIndex = 2 }) { Text("移动历史") }
}
}
when (tabIndex) {
0 -> {
val workOrders = resp.work_orders
if (workOrders.isNullOrEmpty()) {
item { Text("暂无关联工单", Modifier.padding(16.dp), color = Color.Gray) }
} else {
items(workOrders) { wo ->
WorkOrderRow(wo) { onWorkOrderClick(wo.id) }
}
}
}
1 -> {
val customers = resp.customers
if (customers.isNullOrEmpty()) {
item { Text("暂无关联客户", Modifier.padding(16.dp), color = Color.Gray) }
} else {
items(customers) { c ->
CustomerRow(c)
}
}
}
2 -> {
val commits = resp.commits
if (commits.isNullOrEmpty()) {
item { Text("暂无移动记录", Modifier.padding(16.dp), color = Color.Gray) }
} else {
items(commits) { commit ->
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) {
Text(commit.CreatedAt ?: "", fontSize = 12.sp, color = Color.Gray)
Row(verticalAlignment = Alignment.CenterVertically) {
Text(commit.OldContainerBreadcrumb ?: "未入库", fontSize = 13.sp)
Icon(Icons.Default.ArrowForward, null, Modifier.size(16.dp), tint = Color.Gray)
Text(commit.NewContainerBreadcrumb ?: "未入库", fontSize = 13.sp, color = Color(0xFF667EEA))
}
commit.Remark?.let { if (it.isNotBlank()) Text(it, fontSize = 12.sp, color = Color.Gray) }
}
}
}
}
}
@@ -62,9 +238,117 @@ fun ItemDetailScreen(id: Int, onBack: () -> Unit) {
}
}
}
if (showDeleteDialog) {
AlertDialog(
onDismissRequest = { showDeleteDialog = false },
title = { Text("删除物品") },
text = { Text("确定要删除「${item?.name}」吗?此操作不可撤销。") },
confirmButton = {
TextButton(onClick = { showDeleteDialog = false; vm.delete() }) {
Text("确定", color = Color(0xFFF44336))
}
},
dismissButton = { TextButton(onClick = { showDeleteDialog = false }) { Text("取消") } }
)
}
if (showMoveDialog) {
MoveDialog(
currentBreadcrumb = resp?.container_breadcrumb ?: "未入库",
onDismiss = { showMoveDialog = false },
onMove = { newContainerId ->
showMoveDialog = false
vm.move(newContainerId)
}
)
}
if (previewPhotoUrl != null) {
Dialog(onDismissRequest = { previewPhotoUrl = null }) {
Card(Modifier.fillMaxWidth()) {
AsyncImage(model = previewPhotoUrl!!, contentDescription = null, modifier = Modifier.fillMaxWidth(), contentScale = ContentScale.FillWidth)
}
}
}
}
@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) }
private fun WorkOrderRow(wo: LinkedWorkOrder, onClick: () -> Unit = {}) {
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 "不可修")
Card(Modifier.fillMaxWidth().clickable { onClick() }) {
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(wo.title ?: "", fontWeight = FontWeight.Medium)
}
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)
}
}
}
}
@Composable
private fun CustomerRow(c: LinkedCustomerInfo) {
Card(Modifier.fillMaxWidth()) {
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.Person, null, Modifier.size(24.dp), tint = Color(0xFF667EEA))
Spacer(Modifier.width(8.dp))
Column {
val name = listOfNotNull(c.last_name, c.first_name).joinToString(" ").ifEmpty { "未命名" }
Text(name, fontWeight = FontWeight.Medium)
c.primary_phone?.let { Text(it, fontSize = 12.sp, color = Color.Gray) }
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun MoveDialog(currentBreadcrumb: String, onDismiss: () -> Unit, onMove: (Int?) -> Unit) {
var search by remember { mutableStateOf("") }
var results by remember { mutableStateOf<List<com.example.ops_android.data.model.Container>>(emptyList()) }
var selectedId by remember { mutableStateOf<Int?>(null) }
val app = LocalAppContainer.current
fun doSearch() {
kotlinx.coroutines.MainScope().launch {
app.warehouseRepository.listContainer().fold(
onSuccess = { containers ->
results = if (search.isBlank()) containers.take(10)
else containers.filter { (it.name ?: "").contains(search, ignoreCase = true) }.take(10)
},
onFailure = { }
)
}
}
LaunchedEffect(Unit) { doSearch() }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("移动物品") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("当前位置: $currentBreadcrumb", fontSize = 13.sp, color = Color.Gray)
OutlinedTextField(search, { search = it; doSearch() }, label = { Text("搜索目标容器") }, singleLine = true, modifier = Modifier.fillMaxWidth())
TextButton(onClick = { selectedId = null }) {
Text("→ 未入库(取出物品)", color = Color(0xFFFF9800))
}
results.take(5).forEach { c ->
Surface(
Modifier.fillMaxWidth().clickable { selectedId = c.id },
color = if (selectedId == c.id) Color(0xFFE8EAF6) else Color.Transparent
) {
Text(c.name ?: "?", Modifier.padding(8.dp))
}
}
}
},
confirmButton = {
TextButton(onClick = { onMove(selectedId) }) { Text("确认移动") }
},
dismissButton = { TextButton(onClick = onDismiss) { Text("取消") } }
)
}
@@ -1,65 +1,325 @@
package com.example.ops_android.ui.warehouse
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
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.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.FileProvider
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import com.example.ops_android.data.local.SessionManager
import com.example.ops_android.data.model.Customer
import com.example.ops_android.data.remote.api.FileApi
import com.example.ops_android.data.repository.CustomerRepository
import com.example.ops_android.data.repository.WarehouseRepository
import com.example.ops_android.ui.LocalAppContainer
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.launch
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.File
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
data class ItemPhoto(
val uri: Uri,
val hash: String? = null,
val isUploading: Boolean = false
)
class ItemFormViewModel(private val repo: WarehouseRepository, private val containerId: Int, private val editId: Int? = null) : ViewModel() {
data class ItemFormUiState(
val name: String = "",
val serialNumber: String = "",
val quantity: String = "1",
val remark: String = "",
val selectedCustomers: List<Customer> = emptyList(),
val photos: List<ItemPhoto> = emptyList(),
val customerSearchQuery: String = "",
val customerSearchResults: List<Customer> = emptyList(),
val isSearchingCustomers: Boolean = false,
val isSubmitting: Boolean = false,
val errorMessage: String? = null,
val success: Boolean = false
)
class ItemFormViewModel(
private val repo: WarehouseRepository,
private val customerRepo: CustomerRepository,
private val fileApi: FileApi,
private val sessionManager: SessionManager,
private val context: android.content.Context,
private val containerId: Int,
private val editId: Int? = null
) : ViewModel() {
private val _s = MutableStateFlow(ItemFormUiState()); val s: StateFlow<ItemFormUiState> = _s.asStateFlow()
private var customerSearchJob: Job? = null
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) })
fun onQuantity(v: String) {
if (v.isEmpty() || v.all { it.isDigit() }) {
_s.value = _s.value.copy(quantity = v)
}
}
class Factory(private val repo: WarehouseRepository, private val containerId: Int, private val editId: Int? = null) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun <T : ViewModel> create(c: Class<T>): T = ItemFormViewModel(repo, containerId, editId) as T }
fun onRemark(v: String) { _s.value = _s.value.copy(remark = v) }
fun onCustomerSearchQuery(v: String) {
_s.value = _s.value.copy(customerSearchQuery = v)
customerSearchJob?.cancel()
customerSearchJob = viewModelScope.launch {
delay(300)
if (v.isBlank()) {
_s.value = _s.value.copy(customerSearchResults = emptyList(), isSearchingCustomers = false)
return@launch
}
_s.value = _s.value.copy(isSearchingCustomers = true)
customerRepo.list(v).fold(
onSuccess = { _s.value = _s.value.copy(customerSearchResults = it, isSearchingCustomers = false) },
onFailure = { _s.value = _s.value.copy(isSearchingCustomers = false) }
)
}
}
fun addCustomer(c: Customer) {
val current = _s.value.selectedCustomers
if (current.none { it.id == c.id }) {
_s.value = _s.value.copy(selectedCustomers = current + c, customerSearchQuery = "", customerSearchResults = emptyList())
}
}
fun removeCustomer(c: Customer) {
_s.value = _s.value.copy(selectedCustomers = _s.value.selectedCustomers.filter { it.id != c.id })
}
fun addPhoto(uri: Uri) {
val photo = ItemPhoto(uri = uri, isUploading = true)
_s.value = _s.value.copy(photos = _s.value.photos + photo)
viewModelScope.launch {
val inputStream = context.contentResolver.openInputStream(uri)
val bytes = inputStream?.readBytes() ?: return@launch
inputStream.close()
val mime = context.contentResolver.getType(uri) ?: "image/jpeg"
val requestBody = bytes.toRequestBody(mime.toMediaTypeOrNull())
val filePart = MultipartBody.Part.createFormData("file", "photo.jpg", requestBody)
val cookiePart = sessionManager.cachedCookieValue
.toRequestBody("text/plain".toMediaTypeOrNull())
val result = try {
val response = fileApi.uploadImage(cookiePart, filePart)
if (response.errCode == 0 && response.data != null) response.data else null
} catch (e: Exception) { null }
_s.value = _s.value.copy(photos = _s.value.photos.map {
if (it.uri == uri) it.copy(hash = result?.hash, isUploading = false) else it
})
}
}
fun removePhoto(uri: Uri) {
_s.value = _s.value.copy(photos = _s.value.photos.filter { it.uri != uri })
}
fun submit() {
if (_s.value.name.isBlank()) { _s.value = _s.value.copy(errorMessage = "名称不能为空"); return }
val state = _s.value
val qty = state.quantity.toIntOrNull() ?: 1
val data = mutableMapOf<String, Any>(
"name" to state.name,
"serial_number" to state.serialNumber,
"quantity" to qty,
"remark" to state.remark
)
if (containerId > 0) {
data["container_id"] = containerId
}
if (state.selectedCustomers.isNotEmpty()) {
data["customer_ids"] = state.selectedCustomers.map { it.id }
}
val photoHashes = state.photos.mapNotNull { it.hash }
if (photoHashes.isNotEmpty()) {
data["photos"] = photoHashes
}
viewModelScope.launch {
_s.value = _s.value.copy(isSubmitting = true, errorMessage = null)
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 customerRepo: CustomerRepository,
private val fileApi: FileApi,
private val sessionManager: SessionManager,
private val context: android.content.Context,
private val containerId: Int,
private val editId: Int? = null
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(c: Class<T>): T =
ItemFormViewModel(repo, customerRepo, fileApi, sessionManager, context, containerId, editId) as T
}
}
@OptIn(ExperimentalMaterial3Api::class)
private fun createTempImageFile(context: android.content.Context): File {
val dir = File(context.cacheDir, "photos")
if (!dir.exists()) dir.mkdirs()
return File(dir, "capture_${System.currentTimeMillis()}.jpg")
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::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 app = LocalAppContainer.current
val context = LocalContext.current
val vm: ItemFormViewModel = viewModel(
key = "item_form_$editId",
factory = ItemFormViewModel.Factory(app.warehouseRepository, app.customerRepository, app.fileApi, app.sessionManager, context, 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 ->
var showPhotoDialog by remember { mutableStateOf(false) }
var cameraFileUri by remember { mutableStateOf<Uri?>(null) }
val cameraLauncher = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { success ->
val uri = cameraFileUri
if (success && uri != null) vm.addPhoto(uri)
}
val galleryLauncher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
uri?.let { vm.addPhoto(it) }
}
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.name, vm::onName, label = { Text("物品名称 *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
OutlinedTextField(s.serialNumber, vm::onSerial, label = { Text("序列") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
OutlinedTextField(
s.quantity, vm::onQuantity, label = { Text("数量") },
modifier = Modifier.fillMaxWidth(), singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
)
OutlinedTextField(s.remark, vm::onRemark, label = { Text("备注") }, modifier = Modifier.fillMaxWidth(), minLines = 2)
// ── 关联客户 ──
Text("关联客户", fontWeight = FontWeight.Bold, fontSize = 15.sp)
if (s.selectedCustomers.isNotEmpty()) {
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
s.selectedCustomers.forEach { c ->
InputChip(
selected = true, onClick = { vm.removeCustomer(c) },
label = { Text(c.name ?: "?", fontSize = 12.sp) },
trailingIcon = { Icon(Icons.Default.Close, null, Modifier.size(16.dp)) }
)
}
}
}
Column {
OutlinedTextField(s.customerSearchQuery, vm::onCustomerSearchQuery, label = { Text("搜索客户") }, modifier = Modifier.fillMaxWidth(), singleLine = true, leadingIcon = { Icon(Icons.Default.Search, null) })
if (s.isSearchingCustomers) LinearProgressIndicator(Modifier.fillMaxWidth().padding(vertical = 4.dp))
s.customerSearchResults.take(5).forEach { c ->
Surface(Modifier.fillMaxWidth().clickable { vm.addCustomer(c) }, color = Color(0xFFF5F5F5)) {
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.Person, null, Modifier.size(20.dp), tint = Color(0xFF667EEA))
Spacer(Modifier.width(8.dp))
Column(Modifier.weight(1f)) {
Text(c.name ?: "", fontSize = 14.sp)
c.phone?.let { Text(it, fontSize = 12.sp, color = Color.Gray) }
}
Icon(Icons.Default.Add, null, Modifier.size(18.dp), tint = Color(0xFF4CAF50))
}
}
}
}
// ── 图片 ──
Text("照片", fontWeight = FontWeight.Bold, fontSize = 15.sp)
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
s.photos.forEach { photo ->
Box(Modifier.size(80.dp).clip(RoundedCornerShape(8.dp))) {
AsyncImage(model = photo.uri, contentDescription = null, modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
if (photo.isUploading) Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.4f)), contentAlignment = Alignment.Center) { CircularProgressIndicator(Modifier.size(20.dp), color = Color.White, strokeWidth = 2.dp) }
IconButton(onClick = { vm.removePhoto(photo.uri) }, modifier = Modifier.align(Alignment.TopEnd).size(20.dp)) { Icon(Icons.Default.Close, null, Modifier.size(14.dp), tint = Color.White) }
}
}
IconButton(onClick = { showPhotoDialog = true }, modifier = Modifier.size(80.dp).clip(RoundedCornerShape(8.dp)).background(Color(0xFFF0F0F0))) { Icon(Icons.Default.Add, null, Modifier.size(32.dp), tint = Color.Gray) }
}
s.errorMessage?.let { Text(it, color = Color.Red, fontSize = 14.sp) }
Button(onClick = { vm.submit() }, Modifier.fillMaxWidth().height(48.dp), enabled = !s.isSubmitting) { Text("提交") }
Button(
onClick = { vm.submit() }, modifier = Modifier.fillMaxWidth().height(48.dp),
enabled = !s.isSubmitting && s.photos.none { it.isUploading }
) {
if (s.isSubmitting) CircularProgressIndicator(Modifier.size(20.dp), color = Color.White, strokeWidth = 2.dp)
else Text("提交")
}
}
}
if (showPhotoDialog) {
AlertDialog(
onDismissRequest = { showPhotoDialog = false },
title = { Text("添加照片") },
text = {
Column {
TextButton(onClick = {
showPhotoDialog = false
val file = createTempImageFile(context)
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
cameraFileUri = uri
cameraLauncher.launch(uri)
}, modifier = Modifier.fillMaxWidth()) {
Icon(Icons.Default.CameraAlt, null, Modifier.size(24.dp))
Spacer(Modifier.width(12.dp))
Text("拍照", modifier = Modifier.weight(1f))
}
TextButton(onClick = {
showPhotoDialog = false
galleryLauncher.launch("image/*")
}, modifier = Modifier.fillMaxWidth()) {
Icon(Icons.Default.PhotoLibrary, null, Modifier.size(24.dp))
Spacer(Modifier.width(12.dp))
Text("从相册选择", modifier = Modifier.weight(1f))
}
}
},
confirmButton = {}
)
}
}
@@ -10,6 +10,7 @@ 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.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -26,20 +27,21 @@ import com.example.ops_android.ui.LocalAppContainer
@Composable
fun WarehouseScreen(
onItemClick: (Int) -> Unit,
onAddItemClick: (Int) -> Unit = {},
viewModel: WarehouseViewModel = viewModel(factory = WarehouseViewModel.Factory(LocalAppContainer.current.warehouseRepository))
) {
val s by viewModel.s.collectAsState()
var showAddDialog by remember { mutableStateOf(false) }
var showFabMenu by remember { mutableStateOf(false) }
var showAddContainer 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) } }
floatingActionButton = { FloatingActionButton(onClick = { showFabMenu = true }, containerColor = Color(0xFF667EEA)) { Icon(Icons.Default.Add, "新建", tint = Color.White) } }
) { padding ->
Column(Modifier.padding(padding)) {
// Breadcrumb
ScrollableTabRow(selectedTabIndex = s.breadcrumb.size - 1) {
PrimaryScrollableTabRow(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) })
}
@@ -51,27 +53,84 @@ fun WarehouseScreen(
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 ?: "未命名") }
}
// Error
s.errorMessage?.let { error ->
Card(
Modifier.fillMaxWidth().padding(horizontal = 16.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xFFFFEBEE))
) {
Text(error, modifier = Modifier.padding(12.dp), color = Color(0xFFC62828), fontSize = 13.sp)
}
items(s.items) { item ->
ItemRow(item) { onItemClick(item.id) }
}
if (s.isLoading && s.containers.isEmpty()) Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
else PullToRefreshBox(
isRefreshing = s.isRefreshing,
onRefresh = { viewModel.refresh() },
modifier = Modifier.fillMaxSize()
) {
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("取消") } }
)
// FAB menu dialog
if (showFabMenu) {
AlertDialog(
onDismissRequest = { showFabMenu = false },
title = { Text("") },
text = {
Column {
TextButton(onClick = {
showFabMenu = false
newContainerName = ""
showAddContainer = true
}, modifier = Modifier.fillMaxWidth()) {
Icon(Icons.Default.Folder, null, Modifier.size(24.dp))
Spacer(Modifier.width(12.dp))
Text("新建容器", modifier = Modifier.weight(1f))
}
TextButton(onClick = {
showFabMenu = false
onAddItemClick(s.breadcrumb.last().first)
}, modifier = Modifier.fillMaxWidth()) {
Icon(Icons.Default.Inventory, null, Modifier.size(24.dp))
Spacer(Modifier.width(12.dp))
Text("新建物品", modifier = Modifier.weight(1f))
}
}
},
confirmButton = {}
)
}
// Add container dialog
if (showAddContainer) {
AlertDialog(
onDismissRequest = { showAddContainer = false },
title = { Text("新建容器") },
text = { OutlinedTextField(newContainerName, { newContainerName = it }, label = { Text("容器名称") }, singleLine = true) },
confirmButton = {
Button(onClick = {
if (newContainerName.isNotBlank()) {
viewModel.addContainer(newContainerName)
showAddContainer = false
newContainerName = ""
}
}) { Text("创建") }
},
dismissButton = { TextButton(onClick = { showAddContainer = false }) { Text("取消") } }
)
}
}
@Composable
@@ -17,6 +17,7 @@ data class WarehouseUiState(
val breadcrumb: List<Pair<Int, String>> = listOf(0 to "根目录"),
val showItemsOnly: Boolean = false,
val isLoading: Boolean = false,
val isRefreshing: Boolean = false,
val errorMessage: String? = null
)
@@ -25,19 +26,20 @@ class WarehouseViewModel(
) : ViewModel() {
private val _s = MutableStateFlow(WarehouseUiState()); val s: StateFlow<WarehouseUiState> = _s.asStateFlow()
init { loadContainers(0) }
init { loadContainers(null) }
fun loadContainers(parentId: Int) {
fun loadContainers(parentId: Int?) {
viewModelScope.launch {
_s.value = _s.value.copy(isLoading = true)
_s.value = _s.value.copy(isLoading = true, isRefreshing = 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) }
onSuccess = { _s.value = _s.value.copy(containers = it) },
onFailure = { _s.value = _s.value.copy(errorMessage = it.message) }
)
repo.listItem(parentId).fold(
onSuccess = { _s.value = _s.value.copy(items = it) },
onFailure = { }
)
_s.value = _s.value.copy(isLoading = false, isRefreshing = false)
}
}
@@ -51,17 +53,22 @@ class WarehouseViewModel(
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)
loadContainers(entry.first.takeIf { it != 0 })
}
fun toggleItemsOnly() { _s.value = _s.value.copy(showItemsOnly = !_s.value.showItemsOnly) }
fun refresh() { loadContainers(_s.value.breadcrumb.last().first) }
fun refresh() {
loadContainers(_s.value.breadcrumb.last().first.takeIf { it != 0 })
}
fun addContainer(name: String) {
val parentId = _s.value.breadcrumb.last().first
val parentIdOrNull: Int? = parentId.takeIf { it != 0 }
viewModelScope.launch {
repo.addContainer(mapOf("name" to name, "parent_id" to parentId)).fold(
val data = mutableMapOf<String, Any>("title" to name)
if (parentIdOrNull != null) data["parent_id"] = parentIdOrNull
repo.addContainer(data).fold(
onSuccess = { refresh() },
onFailure = { _s.value = _s.value.copy(errorMessage = it.message) }
)
@@ -67,8 +67,9 @@ fun WorkOrderDetailScreen(id: Int, onBack: () -> Unit) {
wo?.let { wo_ ->
val printerManager = com.example.ops_android.ui.LocalAppContainer.current.printerManager
if (printerManager != null) {
val sm = com.example.ops_android.ui.LocalAppContainer.current.sessionManager
IconButton(onClick = {
scope.launch { com.example.ops_android.printer.PrintHelper.printWorkOrderLabel(printerManager, wo_) }
scope.launch { com.example.ops_android.printer.PrintHelper.printWorkOrderLabel(printerManager, wo_, sm.cachedBlackMarkEnabled, sm.cachedUnwindMm) }
}) { Icon(Icons.Default.Print, "打印", tint = Color.White) }
}
}
@@ -1,64 +1,250 @@
package com.example.ops_android.ui.workorder
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
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.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
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.core.content.FileProvider
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.ops_android.data.repository.WorkOrderRepository
import coil.compose.AsyncImage
import com.example.ops_android.data.model.Customer
import com.example.ops_android.data.model.Item
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
import java.io.File
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<WorkOrderFormUiState> = _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 <T : ViewModel> create(c: Class<T>): T = WorkOrderFormViewModel(repo, editId) as T
}
private fun createTempImageFile(context: android.content.Context): File {
val dir = File(context.cacheDir, "photos")
if (!dir.exists()) dir.mkdirs()
return File(dir, "capture_${System.currentTimeMillis()}.jpg")
}
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun WorkOrderFormScreen(editId: Int? = null, onBack: () -> Unit, onSaved: () -> Unit) {
val vm: WorkOrderFormViewModel = viewModel(factory = WorkOrderFormViewModel.Factory(LocalAppContainer.current.workOrderRepository, editId))
fun WorkOrderFormScreen(editId: Int? = null, prefillItemId: Int? = null, onBack: () -> Unit, onSaved: () -> Unit) {
val app = LocalAppContainer.current
val context = LocalContext.current
val vm: WorkOrderFormViewModel = viewModel(
key = "wo_form_$editId",
factory = WorkOrderFormViewModel.Factory(
app.workOrderRepository, app.warehouseRepository, app.customerRepository,
app.fileApi, app.sessionManager, context, editId, prefillItemId
)
)
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)) {
var showPhotoDialog by remember { mutableStateOf(false) }
var cameraFileUri by remember { mutableStateOf<Uri?>(null) }
val cameraLauncher = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { success ->
val uri = cameraFileUri
if (success && uri != null) vm.addPhoto(uri)
}
val galleryLauncher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
uri?.let { vm.addPhoto(it) }
}
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(14.dp)) {
// Title
OutlinedTextField(s.title, vm::onTitle, label = { Text("标题 *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
OutlinedTextField(s.description, vm::onDesc, label = { Text("描述") }, modifier = Modifier.fillMaxWidth(), minLines = 3)
// ── 关联物品 ──
Text("关联物品", fontWeight = FontWeight.Bold, fontSize = 15.sp)
// Selected item chips
if (s.selectedItems.isNotEmpty()) {
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
s.selectedItems.forEach { item ->
InputChip(
selected = true,
onClick = { vm.removeItem(item) },
label = { Text(item.name ?: "?", fontSize = 12.sp) },
trailingIcon = { Icon(Icons.Default.Close, null, Modifier.size(16.dp)) }
)
}
}
}
// Item search
Column {
OutlinedTextField(
s.itemSearchQuery, vm::onItemSearchQuery,
label = { Text("搜索物品") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
leadingIcon = { Icon(Icons.Default.Search, null) }
)
if (s.isSearchingItems) {
LinearProgressIndicator(Modifier.fillMaxWidth().padding(vertical = 4.dp))
}
s.itemSearchResults.take(5).forEach { item ->
Surface(
modifier = Modifier.fillMaxWidth().clickable { vm.addItem(item) },
color = Color(0xFFF5F5F5)
) {
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.Inventory, null, Modifier.size(20.dp), tint = Color(0xFF667EEA))
Spacer(Modifier.width(8.dp))
Column(Modifier.weight(1f)) {
Text(item.name ?: "", fontSize = 14.sp)
item.serial_number?.let { Text(it, fontSize = 12.sp, color = Color.Gray) }
}
Icon(Icons.Default.Add, null, Modifier.size(18.dp), tint = Color(0xFF4CAF50))
}
}
}
}
// ── 关联客户 ──
Text("关联客户", fontWeight = FontWeight.Bold, fontSize = 15.sp)
// Selected customer chips
if (s.selectedCustomers.isNotEmpty()) {
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
s.selectedCustomers.forEach { c ->
InputChip(
selected = true,
onClick = { vm.removeCustomer(c) },
label = { Text(c.name ?: "?", fontSize = 12.sp) },
trailingIcon = { Icon(Icons.Default.Close, null, Modifier.size(16.dp)) }
)
}
}
}
// Customer search
Column {
OutlinedTextField(
s.customerSearchQuery, vm::onCustomerSearchQuery,
label = { Text("搜索客户") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
leadingIcon = { Icon(Icons.Default.Search, null) }
)
if (s.isSearchingCustomers) {
LinearProgressIndicator(Modifier.fillMaxWidth().padding(vertical = 4.dp))
}
s.customerSearchResults.take(5).forEach { c ->
Surface(
modifier = Modifier.fillMaxWidth().clickable { vm.addCustomer(c) },
color = Color(0xFFF5F5F5)
) {
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.Person, null, Modifier.size(20.dp), tint = Color(0xFF667EEA))
Spacer(Modifier.width(8.dp))
Column(Modifier.weight(1f)) {
Text(c.name ?: "", fontSize = 14.sp)
c.phone?.let { Text(it, fontSize = 12.sp, color = Color.Gray) }
}
Icon(Icons.Default.Add, null, Modifier.size(18.dp), tint = Color(0xFF4CAF50))
}
}
}
}
// ── 图片 ──
Text("照片", fontWeight = FontWeight.Bold, fontSize = 15.sp)
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
s.photos.forEach { photo ->
Box(Modifier.size(80.dp).clip(RoundedCornerShape(8.dp))) {
AsyncImage(
model = photo.uri,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
if (photo.isUploading) {
Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.4f)), contentAlignment = Alignment.Center) {
CircularProgressIndicator(Modifier.size(20.dp), color = Color.White, strokeWidth = 2.dp)
}
}
IconButton(
onClick = { vm.removePhoto(photo.uri) },
modifier = Modifier.align(Alignment.TopEnd).size(20.dp)
) {
Icon(Icons.Default.Close, null, Modifier.size(14.dp), tint = Color.White)
}
}
}
IconButton(
onClick = { showPhotoDialog = true },
modifier = Modifier.size(80.dp).clip(RoundedCornerShape(8.dp)).background(Color(0xFFF0F0F0))
) {
Icon(Icons.Default.Add, null, Modifier.size(32.dp), tint = Color.Gray)
}
}
// ── Error & Submit ──
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("提交")
Button(
onClick = { vm.submit() },
modifier = Modifier.fillMaxWidth().height(48.dp),
enabled = !s.isSubmitting && s.photos.none { it.isUploading }
) {
if (s.isSubmitting) CircularProgressIndicator(Modifier.size(20.dp), color = Color.White, strokeWidth = 2.dp)
else Text("提交")
}
}
}
if (showPhotoDialog) {
AlertDialog(
onDismissRequest = { showPhotoDialog = false },
title = { Text("添加照片") },
text = {
Column {
TextButton(onClick = {
showPhotoDialog = false
val file = createTempImageFile(context)
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
cameraFileUri = uri
cameraLauncher.launch(uri)
}, modifier = Modifier.fillMaxWidth()) {
Icon(Icons.Default.CameraAlt, null, Modifier.size(24.dp))
Spacer(Modifier.width(12.dp))
Text("拍照", modifier = Modifier.weight(1f))
}
TextButton(onClick = {
showPhotoDialog = false
galleryLauncher.launch("image/*")
}, modifier = Modifier.fillMaxWidth()) {
Icon(Icons.Default.PhotoLibrary, null, Modifier.size(24.dp))
Spacer(Modifier.width(12.dp))
Text("从相册选择", modifier = Modifier.weight(1f))
}
}
},
confirmButton = {}
)
}
}
@@ -0,0 +1,223 @@
package com.example.ops_android.ui.workorder
import android.content.Context
import android.net.Uri
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.Customer
import com.example.ops_android.data.model.Item
import com.example.ops_android.data.remote.api.FileApi
import com.example.ops_android.data.remote.api.FileUploadResponse
import com.example.ops_android.data.repository.CustomerRepository
import com.example.ops_android.data.repository.WarehouseRepository
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.launch
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.toRequestBody
data class UploadedPhoto(
val uri: Uri,
val hash: String? = null,
val isUploading: Boolean = false
)
data class WorkOrderFormUiState(
val title: String = "",
val description: String = "",
val selectedItems: List<Item> = emptyList(),
val selectedCustomers: List<Customer> = emptyList(),
val photos: List<UploadedPhoto> = emptyList(),
val itemSearchQuery: String = "",
val itemSearchResults: List<Item> = emptyList(),
val isSearchingItems: Boolean = false,
val customerSearchQuery: String = "",
val customerSearchResults: List<Customer> = emptyList(),
val isSearchingCustomers: Boolean = false,
val isSubmitting: Boolean = false,
val errorMessage: String? = null,
val success: Boolean = false
)
class WorkOrderFormViewModel(
private val repo: WorkOrderRepository,
private val warehouseRepo: WarehouseRepository,
private val customerRepo: CustomerRepository,
private val fileApi: FileApi,
private val sessionManager: SessionManager,
private val context: Context,
private val editId: Int? = null,
private val prefillItemId: Int? = null
) : ViewModel() {
private val _s = MutableStateFlow(WorkOrderFormUiState())
val s: StateFlow<WorkOrderFormUiState> = _s.asStateFlow()
private var itemSearchJob: Job? = null
private var customerSearchJob: Job? = null
init {
prefillItemId?.let { itemId ->
viewModelScope.launch {
val result = warehouseRepo.getItem(itemId)
result.fold(
onSuccess = { resp ->
val item = resp.item
if (item != null) {
_s.value = _s.value.copy(
title = item.name ?: "",
description = listOfNotNull(item.serial_number, item.remark).joinToString("\n"),
selectedItems = listOf(item)
)
}
// Pre-fill linked customers
resp.customers?.forEach { cInfo ->
val c = Customer(
id = cInfo.id,
first_name = cInfo.first_name,
last_name = cInfo.last_name,
phone = cInfo.primary_phone
)
_s.value = _s.value.copy(selectedCustomers = _s.value.selectedCustomers + c)
}
},
onFailure = { }
)
}
}
}
fun onTitle(v: String) { _s.value = _s.value.copy(title = v) }
fun onDesc(v: String) { _s.value = _s.value.copy(description = v) }
fun onItemSearchQuery(v: String) {
_s.value = _s.value.copy(itemSearchQuery = v)
itemSearchJob?.cancel()
itemSearchJob = viewModelScope.launch {
delay(300)
if (v.isBlank()) {
_s.value = _s.value.copy(itemSearchResults = emptyList(), isSearchingItems = false)
return@launch
}
_s.value = _s.value.copy(isSearchingItems = true)
warehouseRepo.searchItems(v).fold(
onSuccess = { _s.value = _s.value.copy(itemSearchResults = it, isSearchingItems = false) },
onFailure = { _s.value = _s.value.copy(isSearchingItems = false) }
)
}
}
fun addItem(item: Item) {
val current = _s.value.selectedItems
if (current.none { it.id == item.id }) {
_s.value = _s.value.copy(selectedItems = current + item, itemSearchQuery = "", itemSearchResults = emptyList())
}
}
fun removeItem(item: Item) {
_s.value = _s.value.copy(selectedItems = _s.value.selectedItems.filter { it.id != item.id })
}
fun onCustomerSearchQuery(v: String) {
_s.value = _s.value.copy(customerSearchQuery = v)
customerSearchJob?.cancel()
customerSearchJob = viewModelScope.launch {
delay(300)
if (v.isBlank()) {
_s.value = _s.value.copy(customerSearchResults = emptyList(), isSearchingCustomers = false)
return@launch
}
_s.value = _s.value.copy(isSearchingCustomers = true)
customerRepo.list(v).fold(
onSuccess = { _s.value = _s.value.copy(customerSearchResults = it, isSearchingCustomers = false) },
onFailure = { _s.value = _s.value.copy(isSearchingCustomers = false) }
)
}
}
fun addCustomer(c: Customer) {
val current = _s.value.selectedCustomers
if (current.none { it.id == c.id }) {
_s.value = _s.value.copy(selectedCustomers = current + c, customerSearchQuery = "", customerSearchResults = emptyList())
}
}
fun removeCustomer(c: Customer) {
_s.value = _s.value.copy(selectedCustomers = _s.value.selectedCustomers.filter { it.id != c.id })
}
fun addPhoto(uri: Uri) {
val photo = UploadedPhoto(uri = uri, isUploading = true)
_s.value = _s.value.copy(photos = _s.value.photos + photo)
viewModelScope.launch {
val inputStream = context.contentResolver.openInputStream(uri)
val bytes = inputStream?.readBytes() ?: return@launch
inputStream.close()
val mime = context.contentResolver.getType(uri) ?: "image/jpeg"
val requestBody = bytes.toRequestBody(mime.toMediaTypeOrNull())
val filePart = MultipartBody.Part.createFormData("file", "photo.jpg", requestBody)
val cookiePart = sessionManager.cachedCookieValue
.toRequestBody("text/plain".toMediaTypeOrNull())
val result = try {
val response = fileApi.uploadImage(cookiePart, filePart)
if (response.errCode == 0 && response.data != null) response.data else null
} catch (e: Exception) { null }
_s.value = _s.value.copy(photos = _s.value.photos.map {
if (it.uri == uri) it.copy(hash = result?.hash, isUploading = false)
else it
})
}
}
fun removePhoto(uri: Uri) {
_s.value = _s.value.copy(photos = _s.value.photos.filter { it.uri != uri })
}
fun submit() {
if (_s.value.title.isBlank()) { _s.value = _s.value.copy(errorMessage = "标题不能为空"); return }
val state = _s.value
val data = mutableMapOf<String, Any>(
"title" to state.title,
"description" to state.description
)
if (state.selectedItems.isNotEmpty()) {
data["item_ids"] = state.selectedItems.map { it.id }
}
if (state.selectedCustomers.isNotEmpty()) {
data["customer_ids"] = state.selectedCustomers.map { it.id }
}
val photoHashes = state.photos.mapNotNull { it.hash }
if (photoHashes.isNotEmpty()) {
data["photos"] = photoHashes
}
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 warehouseRepo: WarehouseRepository,
private val customerRepo: CustomerRepository,
private val fileApi: FileApi,
private val sessionManager: SessionManager,
private val context: Context,
private val editId: Int? = null,
private val prefillItemId: Int? = null
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(c: Class<T>): T =
WorkOrderFormViewModel(repo, warehouseRepo, customerRepo, fileApi, sessionManager, context, editId, prefillItemId) as T
}
}
@@ -7,6 +7,7 @@ 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.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -37,29 +38,35 @@ fun WorkOrderListScreen(
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) {
PrimaryScrollableTabRow(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)
else PullToRefreshBox(
isRefreshing = uiState.isRefreshing,
onRefresh = { viewModel.refresh() },
modifier = Modifier.fillMaxSize()
) {
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() } }
}
if (uiState.isLoadingMore) item { Box(Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) { CircularProgressIndicator(Modifier.size(24.dp)) } }
item { LaunchedEffect(Unit) { viewModel.loadMore() } }
}
}
}
@@ -24,6 +24,7 @@ data class WorkOrderListUiState(
val workOrders: List<WorkOrder> = emptyList(),
val selectedStatus: String = "",
val isLoading: Boolean = false,
val isRefreshing: Boolean = false,
val isLoadingMore: Boolean = false,
val hasMore: Boolean = true,
val errorMessage: String? = null
@@ -43,12 +44,12 @@ class WorkOrderListViewModel(
fun refresh() {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true, workOrders = emptyList())
_uiState.value = _uiState.value.copy(isRefreshing = true, workOrders = emptyList())
page = 1
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) }
onSuccess = { (list, _) -> _uiState.value = _uiState.value.copy(workOrders = list, isRefreshing = false, isLoading = false, hasMore = list.size >= pageSize) },
onFailure = { e -> _uiState.value = _uiState.value.copy(isRefreshing = false, isLoading = false, errorMessage = e.message) }
)
}
}
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path name="photos" path="photos/" />
</paths>
+2 -2
View File
@@ -1,3 +1,3 @@
#Thu Jun 25 11:21:25 CST 2026
VERSION_CODE=48
#Thu Jun 25 17:53:49 CST 2026
VERSION_CODE=90
VERSION_NAME=1.2