Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
081884b723 | ||
|
|
b7dab20751 | ||
|
|
a7d81d42ea | ||
|
|
85bcdfccaa | ||
|
|
388c0dcfc5 | ||
|
|
57c5362da3 | ||
|
|
b691545ad4 | ||
|
|
6bc653bb74 | ||
|
|
ae07e12200 | ||
|
|
7a9b3db3ca | ||
|
|
71692a509e | ||
|
|
627099159f | ||
|
|
c493dbc31c |
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="com.example.lc_print_sdk"
|
||||
android:versionCode="1"
|
||||
android:versionName="1.0" >
|
||||
|
||||
<uses-sdk
|
||||
android:minSdkVersion="15"
|
||||
android:targetSdkVersion="30" />
|
||||
|
||||
</manifest>
|
||||
@@ -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,11 @@ 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")
|
||||
private val KEY_LABEL_WIDTH_MM = intPreferencesKey("label_width_mm")
|
||||
private val KEY_LABEL_HEIGHT_MM = intPreferencesKey("label_height_mm")
|
||||
private val KEY_PRINT_RETRACT_MM = intPreferencesKey("print_retract_mm")
|
||||
}
|
||||
|
||||
@Volatile
|
||||
@@ -35,6 +41,26 @@ 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
|
||||
|
||||
@Volatile
|
||||
var cachedLabelWidthMm: Int = 53
|
||||
private set
|
||||
|
||||
@Volatile
|
||||
var cachedLabelHeightMm: Int = 40
|
||||
private set
|
||||
|
||||
@Volatile
|
||||
var cachedPrintRetractMm: Int = 10
|
||||
private set
|
||||
|
||||
fun clearCookieCache() {
|
||||
cachedCookieValue = ""
|
||||
}
|
||||
@@ -43,9 +69,14 @@ 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
|
||||
cachedLabelWidthMm = prefs[KEY_LABEL_WIDTH_MM] ?: 53
|
||||
cachedLabelHeightMm = prefs[KEY_LABEL_HEIGHT_MM] ?: 40
|
||||
cachedPrintRetractMm = prefs[KEY_PRINT_RETRACT_MM] ?: 10
|
||||
}
|
||||
|
||||
suspend fun getApiBaseUrl(): String = cachedBaseUrl.ifEmpty {
|
||||
@@ -92,4 +123,29 @@ 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 }
|
||||
}
|
||||
|
||||
suspend fun setLabelWidthMm(mm: Int) {
|
||||
cachedLabelWidthMm = mm
|
||||
context.dataStore.edit { it[KEY_LABEL_WIDTH_MM] = mm }
|
||||
}
|
||||
|
||||
suspend fun setLabelHeightMm(mm: Int) {
|
||||
cachedLabelHeightMm = mm
|
||||
context.dataStore.edit { it[KEY_LABEL_HEIGHT_MM] = mm }
|
||||
}
|
||||
|
||||
suspend fun setPrintRetractMm(mm: Int) {
|
||||
cachedPrintRetractMm = mm
|
||||
context.dataStore.edit { it[KEY_PRINT_RETRACT_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,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.example.ops_android.data.model
|
||||
|
||||
import com.example.ops_android.data.remote.api.LinkedCustomerInfo
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
data class Item(
|
||||
@@ -8,12 +9,16 @@ 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,
|
||||
@SerializedName("move_history") private val _move_history: List<MoveHistory>? = null
|
||||
@SerializedName("move_history") private val _move_history: List<MoveHistory>? = null,
|
||||
// Filled from detail API
|
||||
@kotlin.jvm.Transient val linkedCustomers: List<LinkedCustomerInfo>? = null,
|
||||
@kotlin.jvm.Transient val containerBreadcrumb: String? = null
|
||||
) {
|
||||
val barcode: String? get() = _barcode
|
||||
val spec: String? get() = _spec
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.example.ops_android.data.model
|
||||
|
||||
import com.example.ops_android.data.remote.api.LinkedCustomer
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
data class WorkOrder(
|
||||
@@ -11,7 +12,8 @@ data class WorkOrder(
|
||||
@SerializedName("UpdatedAt") val updated_at: String? = null,
|
||||
@SerializedName("UserID") val user_id: Int? = null,
|
||||
// Filled from detail API
|
||||
@kotlin.jvm.Transient val commits: List<Commit>? = null
|
||||
@kotlin.jvm.Transient val commits: List<Commit>? = null,
|
||||
@kotlin.jvm.Transient val linkedCustomers: List<LinkedCustomer>? = null
|
||||
)
|
||||
|
||||
data class WorkOrderCommit(
|
||||
|
||||
@@ -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,30 @@ 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) {
|
||||
val resp = response.data
|
||||
val itm = resp.item?.copy(
|
||||
linkedCustomers = resp.customers,
|
||||
containerBreadcrumb = resp.container_breadcrumb
|
||||
)
|
||||
Result.success(resp.copy(item = itm))
|
||||
} else {
|
||||
Result.failure(Exception("获取物品详情失败"))
|
||||
}
|
||||
@@ -125,7 +145,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)
|
||||
|
||||
@@ -29,7 +29,11 @@ class WorkOrderRepository(
|
||||
return try {
|
||||
val response = workOrderApi.get(WorkOrderGetRequest(id))
|
||||
if (response.errCode == 0 && response.data?.order != null) {
|
||||
Result.success(response.data.order)
|
||||
val detail = response.data
|
||||
val wo = detail.order.copy(
|
||||
linkedCustomers = detail.linkedCustomers
|
||||
)
|
||||
Result.success(wo)
|
||||
} else {
|
||||
Result.failure(Exception("获取工单详情失败"))
|
||||
}
|
||||
|
||||
@@ -1,17 +1,156 @@
|
||||
package com.example.ops_android.printer
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.text.Layout
|
||||
import android.text.StaticLayout
|
||||
import android.text.TextPaint
|
||||
import com.example.ops_android.data.model.Item
|
||||
import com.example.ops_android.data.model.PurchaseOrder
|
||||
import com.example.ops_android.data.model.WorkOrder
|
||||
import com.google.zxing.BarcodeFormat
|
||||
import com.google.zxing.EncodeHintType
|
||||
import com.google.zxing.oned.Code128Writer
|
||||
import com.google.zxing.qrcode.QRCodeWriter
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONObject
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
object PrintHelper {
|
||||
|
||||
private val printMutex = Mutex()
|
||||
private const val DOTS_PER_MM = 8
|
||||
|
||||
private fun mmToDots(mm: Int): Int = mm * DOTS_PER_MM
|
||||
|
||||
private fun buildQrContent(module: String, id: Int): String {
|
||||
val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
|
||||
return JSONObject().apply {
|
||||
put("v", "v1")
|
||||
put("module", module)
|
||||
put("id", id)
|
||||
put("printedAt", sdf.format(Date()))
|
||||
put("type", "ops_sys")
|
||||
}.toString()
|
||||
}
|
||||
|
||||
private fun generateQRBitmap(content: String, size: Int): Bitmap {
|
||||
val hints = mapOf(EncodeHintType.MARGIN to 1)
|
||||
val bitMatrix = QRCodeWriter().encode(content, BarcodeFormat.QR_CODE, size, size, hints)
|
||||
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
|
||||
for (y in 0 until size) {
|
||||
for (x in 0 until size) {
|
||||
bitmap.setPixel(x, y, if (bitMatrix[x, y]) Color.BLACK else Color.WHITE)
|
||||
}
|
||||
}
|
||||
return bitmap
|
||||
}
|
||||
|
||||
private fun drawTextBlock(
|
||||
canvas: Canvas,
|
||||
label: String,
|
||||
value: String,
|
||||
x: Float,
|
||||
y: Float,
|
||||
maxWidth: Float,
|
||||
maxLines: Int = 1,
|
||||
textSize: Float = 22f,
|
||||
bold: Boolean = false,
|
||||
lineSpacingExtra: Float = 4f
|
||||
): Float {
|
||||
val text = "$label$value"
|
||||
val paint = TextPaint().apply {
|
||||
color = Color.BLACK
|
||||
this.textSize = textSize
|
||||
isAntiAlias = true
|
||||
isFakeBoldText = bold
|
||||
}
|
||||
|
||||
val builder = StaticLayout.Builder.obtain(text, 0, text.length, paint, maxWidth.toInt())
|
||||
.setAlignment(Layout.Alignment.ALIGN_NORMAL)
|
||||
.setMaxLines(maxLines)
|
||||
.setEllipsize(android.text.TextUtils.TruncateAt.END)
|
||||
.setLineSpacing(lineSpacingExtra, 1f)
|
||||
|
||||
val layout = builder.build()
|
||||
canvas.save()
|
||||
canvas.translate(x, y)
|
||||
layout.draw(canvas)
|
||||
canvas.restore()
|
||||
|
||||
return y + layout.height
|
||||
}
|
||||
|
||||
private fun buildWorkOrderLabelBitmap(wo: WorkOrder, widthDots: Int, heightDots: Int): Bitmap {
|
||||
val bitmap = Bitmap.createBitmap(widthDots, heightDots, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(bitmap)
|
||||
canvas.drawColor(Color.WHITE)
|
||||
|
||||
val padding = 12
|
||||
val dividerX = (widthDots * 0.48).toInt()
|
||||
val leftInnerWidth = dividerX - padding * 2
|
||||
val qrSize = (leftInnerWidth * 0.95).toInt().coerceAtLeast(140)
|
||||
val qrX = (dividerX - qrSize) / 2
|
||||
val qrY = padding
|
||||
|
||||
val qrContent = buildQrContent("workorder", wo.id)
|
||||
val qrBitmap = generateQRBitmap(qrContent, qrSize)
|
||||
canvas.drawBitmap(qrBitmap, qrX.toFloat(), qrY.toFloat(), null)
|
||||
|
||||
val sepPaint = Paint().apply {
|
||||
color = Color.BLACK
|
||||
strokeWidth = 1.5f
|
||||
isAntiAlias = true
|
||||
}
|
||||
canvas.drawLine(dividerX.toFloat(), padding.toFloat(), dividerX.toFloat(), (heightDots - padding).toFloat(), sepPaint)
|
||||
|
||||
val textX = (dividerX + padding).toFloat()
|
||||
val textMaxWidth = (widthDots - dividerX - padding * 2).toFloat()
|
||||
var textY = (padding - 2).toFloat()
|
||||
val maxTextY = (heightDots - padding).toFloat()
|
||||
|
||||
textY = drawTextBlock(canvas, "", "工单:${wo.id}", textX, textY, textMaxWidth, textSize = 32f, bold = true, lineSpacingExtra = 6f)
|
||||
|
||||
if (textY < maxTextY) {
|
||||
textY = drawTextBlock(canvas, "", "标题:${wo.title ?: "-"}", textX, textY, textMaxWidth, maxLines = 3, textSize = 28f, lineSpacingExtra = 4f)
|
||||
}
|
||||
|
||||
if (textY < maxTextY) {
|
||||
textY = drawTextBlock(canvas, "", "描述:${wo.description ?: "-"}", textX, textY, textMaxWidth, maxLines = 6, textSize = 28f, lineSpacingExtra = 4f)
|
||||
}
|
||||
|
||||
val customerNames = wo.linkedCustomers
|
||||
?.mapNotNull { c ->
|
||||
listOfNotNull(c.first_name, c.last_name)
|
||||
.joinToString(" ")
|
||||
.ifBlank { null }
|
||||
}
|
||||
?.joinToString(", ")
|
||||
?: "-"
|
||||
val customerText = "客户:$customerNames"
|
||||
val customerY = qrY + qrSize + 4
|
||||
val leftTextMaxWidth = (dividerX - padding * 2).toFloat()
|
||||
if (customerY < heightDots - padding) {
|
||||
drawTextBlock(canvas, "", customerText, padding.toFloat(), customerY.toFloat(), leftTextMaxWidth, maxLines = 2, textSize = 28f, lineSpacingExtra = 4f)
|
||||
}
|
||||
|
||||
return bitmap
|
||||
}
|
||||
|
||||
suspend fun printOrderLabel(
|
||||
printerManager: PrinterManager,
|
||||
order: PurchaseOrder
|
||||
): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
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)
|
||||
@@ -20,94 +159,182 @@ object PrintHelper {
|
||||
printerManager.setFontSize(20)
|
||||
printerManager.setBold(false)
|
||||
if (order.id > 0) {
|
||||
printerManager.addBarcode("PO-${order.id}").fold(
|
||||
onSuccess = {},
|
||||
onFailure = {}
|
||||
)
|
||||
printerManager.addBarcode("PO-${order.id}")
|
||||
}
|
||||
if (blackMarkEnabled) {
|
||||
printerManager.addLineFeed(2)
|
||||
printerManager.commit().fold(
|
||||
onSuccess = {
|
||||
Thread.sleep(1000)
|
||||
printerManager.closeDevice()
|
||||
Result.success(Unit)
|
||||
},
|
||||
onFailure = { Result.failure(it) }
|
||||
)
|
||||
printerManager.enableMarkDetection(true)
|
||||
} else {
|
||||
printerManager.addLineFeed((unwindMm / 7).toInt().coerceAtLeast(2))
|
||||
}
|
||||
printerManager.commit()
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun printWorkOrderLabel(
|
||||
printerManager: PrinterManager,
|
||||
workOrder: WorkOrder
|
||||
): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
workOrder: WorkOrder,
|
||||
labelWidthMm: Int = 53,
|
||||
labelHeightMm: Int = 40,
|
||||
blackMarkEnabled: Boolean = true,
|
||||
unwindMm: Int = 40,
|
||||
printRetractMm: Int = 10
|
||||
): 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}").fold(
|
||||
onSuccess = {},
|
||||
onFailure = {}
|
||||
)
|
||||
if (printRetractMm > 0) {
|
||||
printerManager.setUnwindLength(mmToDots(printRetractMm))
|
||||
}
|
||||
val widthDots = mmToDots(labelWidthMm)
|
||||
val heightDots = mmToDots(labelHeightMm)
|
||||
val bitmap = buildWorkOrderLabelBitmap(workOrder, widthDots, heightDots)
|
||||
printerManager.printImage(bitmap, 1)
|
||||
if (blackMarkEnabled) {
|
||||
printerManager.addLineFeed(2)
|
||||
printerManager.commit().fold(
|
||||
onSuccess = {
|
||||
Thread.sleep(1000)
|
||||
printerManager.closeDevice()
|
||||
Result.success(Unit)
|
||||
},
|
||||
onFailure = { Result.failure(it) }
|
||||
)
|
||||
printerManager.enableMarkDetection(true)
|
||||
} else {
|
||||
printerManager.addLineFeed((unwindMm / 7).toInt().coerceAtLeast(2))
|
||||
}
|
||||
printerManager.commit()
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildItemLabelBitmap(item: Item, widthDots: Int, heightDots: Int): Bitmap {
|
||||
val bitmap = Bitmap.createBitmap(widthDots, heightDots, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(bitmap)
|
||||
canvas.drawColor(Color.WHITE)
|
||||
|
||||
val padding = 12
|
||||
val barcodeReserve = if (!item.serial_number.isNullOrBlank()) 70 else 0
|
||||
val dividerX = (widthDots * 0.48).toInt()
|
||||
val leftInnerWidth = dividerX - padding * 2
|
||||
val qrSize = (leftInnerWidth * 0.95).toInt().coerceAtLeast(140)
|
||||
val qrX = (dividerX - qrSize) / 2
|
||||
val qrY = padding
|
||||
|
||||
val qrContent = buildQrContent("item", item.id)
|
||||
val qrBitmap = generateQRBitmap(qrContent, qrSize)
|
||||
canvas.drawBitmap(qrBitmap, qrX.toFloat(), qrY.toFloat(), null)
|
||||
|
||||
val sepPaint = Paint().apply {
|
||||
color = Color.BLACK
|
||||
strokeWidth = 1.5f
|
||||
isAntiAlias = true
|
||||
}
|
||||
|
||||
val textX = (dividerX + padding).toFloat()
|
||||
val textMaxWidth = (widthDots - dividerX - padding * 2).toFloat()
|
||||
var textY = (padding - 2).toFloat()
|
||||
val maxTextY = heightDots - padding - barcodeReserve
|
||||
|
||||
textY = drawTextBlock(canvas, "", "物品:${item.id}", textX, textY, textMaxWidth, textSize = 32f, bold = true, lineSpacingExtra = 6f)
|
||||
|
||||
if (textY < maxTextY) {
|
||||
textY = drawTextBlock(canvas, "", "名称:${item.name ?: "-"}", textX, textY, textMaxWidth, maxLines = 3, textSize = 25f, lineSpacingExtra = 4f)
|
||||
}
|
||||
|
||||
if (textY < maxTextY && item.quantity != 1) {
|
||||
textY = drawTextBlock(canvas, "", "数量:${item.quantity}", textX, textY, textMaxWidth, textSize = 25f, lineSpacingExtra = 4f)
|
||||
}
|
||||
|
||||
if (textY < maxTextY && !item.containerBreadcrumb.isNullOrBlank()) {
|
||||
textY = drawTextBlock(canvas, "", "位置:${item.containerBreadcrumb}", textX, textY, textMaxWidth, maxLines = 4, textSize = 25f, lineSpacingExtra = 4f)
|
||||
}
|
||||
|
||||
canvas.drawLine(dividerX.toFloat(), padding.toFloat(), dividerX.toFloat(), maxTextY.toFloat(), sepPaint)
|
||||
|
||||
if (!item.linkedCustomers.isNullOrEmpty()) {
|
||||
val customerNames = item.linkedCustomers
|
||||
.mapNotNull { c ->
|
||||
listOfNotNull(c.first_name, c.last_name)
|
||||
.joinToString(" ")
|
||||
.ifBlank { null }
|
||||
}
|
||||
.joinToString(", ")
|
||||
val customerText = "客户:$customerNames"
|
||||
val customerY = qrY + qrSize + 4
|
||||
val leftTextMaxWidth = (dividerX - padding * 2).toFloat()
|
||||
if (customerY < maxTextY) {
|
||||
drawTextBlock(canvas, "", customerText, padding.toFloat(), customerY.toFloat(), leftTextMaxWidth, maxLines = 2, textSize = 20f, lineSpacingExtra = 2f)
|
||||
}
|
||||
}
|
||||
|
||||
if (!item.serial_number.isNullOrBlank()) {
|
||||
canvas.drawLine(padding.toFloat(), maxTextY.toFloat(), (widthDots - padding).toFloat(), maxTextY.toFloat(), sepPaint)
|
||||
|
||||
val snTextY = maxTextY + 4
|
||||
val snTextHeight = drawTextBlock(canvas, "", item.serial_number ?: "",
|
||||
padding.toFloat(), snTextY.toFloat(),
|
||||
(widthDots - padding * 2).toFloat(),
|
||||
maxLines = 1, textSize = 22f, lineSpacingExtra = 0f
|
||||
) - snTextY
|
||||
|
||||
val barcodeWidth = widthDots - padding * 4
|
||||
val barcodeHeight = 35
|
||||
val barcodeX = (widthDots - barcodeWidth) / 2
|
||||
val barcodeY = snTextY + snTextHeight + 4
|
||||
|
||||
val code128 = Code128Writer()
|
||||
val barcodeMatrix = code128.encode(item.serial_number, BarcodeFormat.CODE_128, barcodeWidth, barcodeHeight)
|
||||
val barcodeBitmap = Bitmap.createBitmap(barcodeWidth, barcodeHeight, Bitmap.Config.ARGB_8888)
|
||||
for (y in 0 until barcodeHeight) {
|
||||
for (x in 0 until barcodeWidth) {
|
||||
barcodeBitmap.setPixel(x, y, if (barcodeMatrix[x, y]) Color.BLACK else Color.WHITE)
|
||||
}
|
||||
}
|
||||
canvas.drawBitmap(barcodeBitmap, barcodeX.toFloat(), barcodeY.toFloat(), null)
|
||||
}
|
||||
|
||||
return bitmap
|
||||
}
|
||||
|
||||
suspend fun printItemLabel(
|
||||
printerManager: PrinterManager,
|
||||
item: Item
|
||||
): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
item: Item,
|
||||
labelWidthMm: Int = 53,
|
||||
labelHeightMm: Int = 40,
|
||||
blackMarkEnabled: Boolean = true,
|
||||
unwindMm: Int = 40,
|
||||
printRetractMm: Int = 10
|
||||
): 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") }
|
||||
item.spec?.let { printerManager.addText("规格: $it\n") }
|
||||
if (item.id > 0) {
|
||||
printerManager.addBarcode("ITEM-${item.id}").fold(
|
||||
onSuccess = {},
|
||||
onFailure = {}
|
||||
)
|
||||
if (printRetractMm > 0) {
|
||||
printerManager.setUnwindLength(mmToDots(printRetractMm))
|
||||
}
|
||||
val widthDots = mmToDots(labelWidthMm)
|
||||
val heightDots = mmToDots(labelHeightMm)
|
||||
val bitmap = buildItemLabelBitmap(item, widthDots, heightDots)
|
||||
printerManager.printImage(bitmap, 1)
|
||||
if (blackMarkEnabled) {
|
||||
printerManager.addLineFeed(2)
|
||||
printerManager.commit().fold(
|
||||
onSuccess = {
|
||||
Thread.sleep(1000)
|
||||
printerManager.closeDevice()
|
||||
Result.success(Unit)
|
||||
},
|
||||
onFailure = { Result.failure(it) }
|
||||
)
|
||||
printerManager.enableMarkDetection(true)
|
||||
} else {
|
||||
printerManager.addLineFeed((unwindMm / 7).toInt().coerceAtLeast(2))
|
||||
}
|
||||
printerManager.commit()
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun printContainerLabel(
|
||||
printerManager: PrinterManager,
|
||||
containerName: String,
|
||||
barcode: String? = null
|
||||
): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
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)
|
||||
@@ -116,22 +343,18 @@ object PrintHelper {
|
||||
if (barcode != null) {
|
||||
printerManager.setFontSize(18)
|
||||
printerManager.setBold(false)
|
||||
printerManager.addBarcode(barcode).fold(
|
||||
onSuccess = {},
|
||||
onFailure = {}
|
||||
)
|
||||
printerManager.addBarcode(barcode)
|
||||
}
|
||||
if (blackMarkEnabled) {
|
||||
printerManager.addLineFeed(2)
|
||||
printerManager.commit().fold(
|
||||
onSuccess = {
|
||||
Thread.sleep(1000)
|
||||
printerManager.closeDevice()
|
||||
Result.success(Unit)
|
||||
},
|
||||
onFailure = { Result.failure(it) }
|
||||
)
|
||||
printerManager.enableMarkDetection(true)
|
||||
} else {
|
||||
printerManager.addLineFeed((unwindMm / 7).toInt().coerceAtLeast(2))
|
||||
}
|
||||
printerManager.commit()
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,8 +46,6 @@ class PrintJob(private val manager: PrinterManager) {
|
||||
if (lineFeeds > 0) manager.addLineFeed(lineFeeds).getOrThrow()
|
||||
|
||||
manager.commit().getOrThrow()
|
||||
Thread.sleep(1500)
|
||||
manager.closeDevice().getOrThrow()
|
||||
}
|
||||
|
||||
/** 初始化 → 添加内容 → 提交(不关闭) */
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// 黑标 / 标签
|
||||
// ══════════════════════════════════════════════════════════
|
||||
@@ -287,8 +301,6 @@ class PrinterManager(private val context: Context) {
|
||||
initDevice().getOrThrow()
|
||||
addText(text).getOrThrow()
|
||||
commit().getOrThrow()
|
||||
Thread.sleep(1500)
|
||||
closeDevice().getOrThrow()
|
||||
}
|
||||
|
||||
fun quickLabel(text: String): Result<Unit> = runCatching {
|
||||
@@ -296,8 +308,6 @@ class PrinterManager(private val context: Context) {
|
||||
enableMarkDetection(true).getOrThrow()
|
||||
addText(text).getOrThrow()
|
||||
commit().getOrThrow()
|
||||
Thread.sleep(1500)
|
||||
closeDevice().getOrThrow()
|
||||
}
|
||||
|
||||
/** 创建链式打印任务 */
|
||||
|
||||
@@ -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,10 +44,16 @@ fun HomeScreen(
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
LazyColumn(
|
||||
PullToRefreshBox(
|
||||
isRefreshing = uiState.isRefreshing,
|
||||
onRefresh = { viewModel.refresh() },
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
@@ -73,6 +80,21 @@ fun HomeScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// ── 快捷入口 ──────────────────────────
|
||||
item {
|
||||
Text("快捷入口", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
QuickLinkCard(Icons.Default.Search, "搜索", Color(0xFF4CAF50), Modifier.weight(1f), onSearchClick)
|
||||
QuickLinkCard(Icons.Default.Event, "日程", Color(0xFF9C27B0), Modifier.weight(1f), onScheduleClick)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 今日日程 ──────────────────────────
|
||||
item {
|
||||
Text("今日日程", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
@@ -165,20 +187,6 @@ fun HomeScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 快捷入口 ──────────────────────────
|
||||
item {
|
||||
Text("快捷入口", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
QuickLinkCard(Icons.Default.Search, "搜索", Color(0xFF4CAF50), Modifier.weight(1f), onSearchClick)
|
||||
QuickLinkCard(Icons.Default.Event, "日程", Color(0xFF9C27B0), Modifier.weight(1f), onScheduleClick)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
@@ -102,7 +111,13 @@ fun OPSNavGraph(isLoggedIn: Boolean) {
|
||||
onWorkOrderClick = { id -> rootNavController.navigate(Routes.workOrderDetail(id)) },
|
||||
onItemClick = { id -> rootNavController.navigate(Routes.itemDetail(id)) },
|
||||
onContainerClick = { id -> rootNavController.navigate(Routes.MAIN) },
|
||||
onBack = { rootNavController.popBackStack() }
|
||||
onBack = { rootNavController.popBackStack() },
|
||||
onOpsSysNavigate = { module, id ->
|
||||
when (module) {
|
||||
"workorder" -> rootNavController.navigate(Routes.workOrderDetail(id))
|
||||
"item" -> rootNavController.navigate(Routes.itemDetail(id))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
composable(Routes.SCHEDULE) {
|
||||
@@ -120,13 +135,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 +207,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,6 +90,11 @@ fun OrderListScreen(
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
PullToRefreshBox(
|
||||
isRefreshing = uiState.isRefreshing,
|
||||
onRefresh = { viewModel.refresh() },
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(8.dp),
|
||||
@@ -118,6 +124,7 @@ fun OrderListScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -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)
|
||||
) {
|
||||
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?
|
||||
|
||||
@@ -28,7 +28,7 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
@@ -51,7 +51,8 @@ fun SearchScreen(
|
||||
onWorkOrderClick: (Int) -> Unit,
|
||||
onItemClick: (Int) -> Unit,
|
||||
onContainerClick: (Int) -> Unit,
|
||||
onBack: () -> Unit
|
||||
onBack: () -> Unit,
|
||||
onOpsSysNavigate: (module: String, id: Int) -> Unit
|
||||
) {
|
||||
val appContainer = LocalAppContainer.current
|
||||
val viewModel: SearchViewModel = viewModel(
|
||||
@@ -67,6 +68,13 @@ fun SearchScreen(
|
||||
if (granted) showScanner = true
|
||||
}
|
||||
|
||||
LaunchedEffect(uiState.opsSysNavigation) {
|
||||
uiState.opsSysNavigation?.let { navInfo ->
|
||||
onOpsSysNavigate(navInfo.module, navInfo.id)
|
||||
viewModel.clearOpsSysNavigation()
|
||||
}
|
||||
}
|
||||
|
||||
fun checkCameraAndScan() {
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
|
||||
hasCameraPermission = true
|
||||
@@ -128,17 +136,9 @@ fun SearchScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Prefix hints
|
||||
Text(
|
||||
"前缀快捷搜索: po:采购单 wo:工单 item:物品 warehouse:容器",
|
||||
fontSize = 11.sp,
|
||||
color = Color.Gray,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
// Category tabs
|
||||
ScrollableTabRow(
|
||||
PrimaryScrollableTabRow(
|
||||
selectedTabIndex = SearchCategory.entries.indexOf(uiState.category),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
edgePadding = 8.dp
|
||||
|
||||
@@ -7,7 +7,6 @@ import com.example.ops_android.data.model.Container
|
||||
import com.example.ops_android.data.model.Item
|
||||
import com.example.ops_android.data.model.PurchaseOrder
|
||||
import com.example.ops_android.data.model.WorkOrder
|
||||
import com.example.ops_android.data.repository.CustomerRepository
|
||||
import com.example.ops_android.data.repository.OrderRepository
|
||||
import com.example.ops_android.data.repository.WarehouseRepository
|
||||
import com.example.ops_android.data.repository.WorkOrderRepository
|
||||
@@ -15,6 +14,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import org.json.JSONObject
|
||||
|
||||
enum class SearchCategory(val label: String) {
|
||||
ALL("全部"),
|
||||
@@ -24,6 +24,11 @@ enum class SearchCategory(val label: String) {
|
||||
CONTAINER("容器")
|
||||
}
|
||||
|
||||
data class OpsSysNavInfo(
|
||||
val module: String,
|
||||
val id: Int
|
||||
)
|
||||
|
||||
data class SearchUiState(
|
||||
val query: String = "",
|
||||
val category: SearchCategory = SearchCategory.ALL,
|
||||
@@ -32,7 +37,8 @@ data class SearchUiState(
|
||||
val items: List<Item> = emptyList(),
|
||||
val containers: List<Container> = emptyList(),
|
||||
val isSearching: Boolean = false,
|
||||
val errorMessage: String? = null
|
||||
val errorMessage: String? = null,
|
||||
val opsSysNavigation: OpsSysNavInfo? = null
|
||||
)
|
||||
|
||||
class SearchViewModel(
|
||||
@@ -61,7 +67,19 @@ class SearchViewModel(
|
||||
val query = _uiState.value.query.trim()
|
||||
if (query.isEmpty()) return
|
||||
|
||||
// Parse prefix shortcuts
|
||||
val jsonObj = try { JSONObject(query) } catch (e: Exception) { null }
|
||||
if (jsonObj != null) {
|
||||
_uiState.value = _uiState.value.copy(query = "")
|
||||
if (jsonObj.optString("type") == "ops_sys") {
|
||||
val module = jsonObj.optString("module")
|
||||
val id = jsonObj.optInt("id")
|
||||
if (module.isNotBlank() && id > 0) {
|
||||
_uiState.value = _uiState.value.copy(opsSysNavigation = OpsSysNavInfo(module, id))
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val (actualCategory, actualQuery) = parsePrefix(query)
|
||||
_uiState.value = _uiState.value.copy(category = actualCategory)
|
||||
|
||||
@@ -125,6 +143,10 @@ class SearchViewModel(
|
||||
_uiState.value = SearchUiState(query = _uiState.value.query, category = _uiState.value.category)
|
||||
}
|
||||
|
||||
fun clearOpsSysNavigation() {
|
||||
_uiState.value = _uiState.value.copy(opsSysNavigation = null)
|
||||
}
|
||||
|
||||
private fun parsePrefix(query: String): Pair<SearchCategory, String> {
|
||||
val prefixes = mapOf(
|
||||
"po:" to SearchCategory.ORDER,
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
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.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()
|
||||
}
|
||||
},
|
||||
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()
|
||||
}
|
||||
},
|
||||
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()
|
||||
}
|
||||
},
|
||||
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,75 @@ 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("标签尺寸", style = MaterialTheme.typography.titleMedium)
|
||||
|
||||
OutlinedTextField(
|
||||
value = uiState.labelWidthMm,
|
||||
onValueChange = viewModel::onLabelWidthChange,
|
||||
label = { Text("标签宽度 (mm)") },
|
||||
suffix = { Text("mm") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = uiState.labelHeightMm,
|
||||
onValueChange = viewModel::onLabelHeightChange,
|
||||
label = { Text("标签高度 (mm)") },
|
||||
suffix = { Text("mm") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Button(onClick = { viewModel.saveLabelSize() }, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("保存标签尺寸")
|
||||
}
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
|
||||
Text("打印校准", style = MaterialTheme.typography.titleMedium)
|
||||
|
||||
OutlinedTextField(
|
||||
value = uiState.printRetractMm,
|
||||
onValueChange = viewModel::onPrintRetractMmChange,
|
||||
label = { Text("打印前回退长度 (mm)") },
|
||||
suffix = { Text("mm") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Button(onClick = { viewModel.savePrintRetractMm() }, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("保存回退长度")
|
||||
}
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
|
||||
Text(
|
||||
text = "关于",
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
|
||||
@@ -23,7 +23,12 @@ 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",
|
||||
val labelWidthMm: String = "53",
|
||||
val labelHeightMm: String = "40",
|
||||
val printRetractMm: String = "10"
|
||||
)
|
||||
|
||||
class SettingsViewModel(
|
||||
@@ -41,6 +46,7 @@ class SettingsViewModel(
|
||||
|
||||
init {
|
||||
loadCurrentUrl()
|
||||
loadPrinterSettings()
|
||||
}
|
||||
|
||||
private fun loadCurrentUrl() {
|
||||
@@ -50,10 +56,75 @@ class SettingsViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadPrinterSettings() {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
blackMarkEnabled = sessionManager.cachedBlackMarkEnabled,
|
||||
unwindMm = sessionManager.cachedUnwindMm.toString(),
|
||||
labelWidthMm = sessionManager.cachedLabelWidthMm.toString(),
|
||||
labelHeightMm = sessionManager.cachedLabelHeightMm.toString(),
|
||||
printRetractMm = sessionManager.cachedPrintRetractMm.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 onLabelWidthChange(value: String) {
|
||||
if (value.isEmpty() || value.all { it.isDigit() } && value.toIntOrNull()?.let { it in 10..200 } == true) {
|
||||
_uiState.value = _uiState.value.copy(labelWidthMm = value)
|
||||
}
|
||||
}
|
||||
|
||||
fun onLabelHeightChange(value: String) {
|
||||
if (value.isEmpty() || value.all { it.isDigit() } && value.toIntOrNull()?.let { it in 10..200 } == true) {
|
||||
_uiState.value = _uiState.value.copy(labelHeightMm = value)
|
||||
}
|
||||
}
|
||||
|
||||
fun saveLabelSize() {
|
||||
val width = _uiState.value.labelWidthMm.toIntOrNull() ?: 53
|
||||
val height = _uiState.value.labelHeightMm.toIntOrNull() ?: 40
|
||||
viewModelScope.launch {
|
||||
sessionManager.setLabelWidthMm(width)
|
||||
sessionManager.setLabelHeightMm(height)
|
||||
_uiState.value = _uiState.value.copy(message = "标签尺寸已保存")
|
||||
}
|
||||
}
|
||||
|
||||
fun onPrintRetractMmChange(value: String) {
|
||||
if (value.isEmpty() || value.all { it.isDigit() } && value.toIntOrNull()?.let { it in 0..50 } == true) {
|
||||
_uiState.value = _uiState.value.copy(printRetractMm = value)
|
||||
}
|
||||
}
|
||||
|
||||
fun savePrintRetractMm() {
|
||||
val mm = _uiState.value.printRetractMm.toIntOrNull() ?: 10
|
||||
viewModelScope.launch {
|
||||
sessionManager.setPrintRetractMm(mm)
|
||||
_uiState.value = _uiState.value.copy(message = "回退长度已保存")
|
||||
}
|
||||
}
|
||||
|
||||
fun saveUrl() {
|
||||
viewModelScope.launch {
|
||||
val url = _uiState.value.apiBaseUrl.trim()
|
||||
|
||||
@@ -1,60 +1,246 @@
|
||||
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.printer.PrintHelper
|
||||
import com.example.ops_android.ui.LocalAppContainer
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class ItemDetailUiState(val item: Item? = null, val isLoading: Boolean = false, val errorMessage: String? = null)
|
||||
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 = {
|
||||
val scope = rememberCoroutineScope()
|
||||
item?.let { itm ->
|
||||
val printerManager = app.printerManager
|
||||
if (printerManager != null) {
|
||||
IconButton(onClick = {
|
||||
scope.launch { PrintHelper.printItemLabel(printerManager, itm, labelWidthMm = app.sessionManager.cachedLabelWidthMm, labelHeightMm = app.sessionManager.cachedLabelHeightMm, blackMarkEnabled = app.sessionManager.cachedBlackMarkEnabled, unwindMm = app.sessionManager.cachedUnwindMm, printRetractMm = app.sessionManager.cachedPrintRetractMm) }
|
||||
}) {
|
||||
Icon(Icons.Default.Print, "打印", tint = Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
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 ->
|
||||
// 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)) {
|
||||
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) }
|
||||
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 +248,119 @@ 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 onQuantity(v: String) {
|
||||
if (v.isEmpty() || v.all { it.isDigit() }) {
|
||||
_s.value = _s.value.copy(quantity = v)
|
||||
}
|
||||
}
|
||||
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 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)
|
||||
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)
|
||||
_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) })
|
||||
r.fold(
|
||||
onSuccess = { _s.value = _s.value.copy(isSubmitting = false, success = true) },
|
||||
onFailure = { _s.value = _s.value.copy(isSubmitting = false, errorMessage = it.message) }
|
||||
)
|
||||
}
|
||||
}
|
||||
class Factory(private val repo: WarehouseRepository, private val containerId: Int, private val editId: Int? = null) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun <T : ViewModel> create(c: Class<T>): T = ItemFormViewModel(repo, containerId, editId) as T }
|
||||
|
||||
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 ->
|
||||
Column(Modifier.fillMaxSize().padding(padding).padding(16.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
OutlinedTextField(s.name, vm::onName, label = { Text("名称 *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
|
||||
OutlinedTextField(s.serialNumber, vm::onSerial, label = { Text("编号") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
|
||||
OutlinedTextField(s.spec, vm::onSpec, label = { Text("规格") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
|
||||
OutlinedTextField(s.remark, vm::onRemark, label = { Text("备注") }, modifier = Modifier.fillMaxWidth(), minLines = 2)
|
||||
s.errorMessage?.let { Text(it, color = Color.Red, fontSize = 14.sp) }
|
||||
Button(onClick = { vm.submit() }, Modifier.fillMaxWidth().height(48.dp), enabled = !s.isSubmitting) { Text("提交") }
|
||||
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.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 = 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,8 +53,23 @@ 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)) {
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
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 ?: "未命名") }
|
||||
@@ -64,14 +81,56 @@ fun WarehouseScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showAddDialog) AlertDialog(
|
||||
onDismissRequest = { showAddDialog = false },
|
||||
// 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); showAddDialog = false; newContainerName = "" } }) { Text("创建") } },
|
||||
dismissButton = { TextButton(onClick = { showAddDialog = false }) { Text("取消") } }
|
||||
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_, labelWidthMm = sm.cachedLabelWidthMm, labelHeightMm = sm.cachedLabelHeightMm, blackMarkEnabled = sm.cachedBlackMarkEnabled, unwindMm = sm.cachedUnwindMm, printRetractMm = sm.cachedPrintRetractMm) }
|
||||
}) { 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,13 +38,18 @@ 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)) {
|
||||
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) {
|
||||
@@ -63,4 +69,5 @@ fun WorkOrderListScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 982 B |
|
After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 7.6 KiB |
@@ -1,3 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">ops_android</string>
|
||||
<string name="app_name">Operations 2</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<cache-path name="photos" path="photos/" />
|
||||
</paths>
|
||||
@@ -1,3 +1,3 @@
|
||||
#Thu Jun 25 11:21:25 CST 2026
|
||||
VERSION_CODE=48
|
||||
#Fri Jun 26 20:46:06 CST 2026
|
||||
VERSION_CODE=121
|
||||
VERSION_NAME=1.2
|
||||
|
||||