Compare commits

...
13 Commits
Author SHA1 Message Date
kevin 081884b723 Signed-off-by: 无闻风 <wuwenfengmi@gmail.com> 2026-06-26 20:47:09 +08:00
kevin b7dab20751 feat: 搜索页支持JSON扫码直达,去掉前缀提示
- 搜索输入框内容若是JSON则解码,type为ops_sys时根据module跳转(workorder→工单详情,item→物品详情)
- JSON解码后无论type是否为ops_sys都清空输入框
- 非JSON内容走原有关键字搜索逻辑
- 去掉前缀快捷搜索提示文本
2026-06-26 20:46:58 +08:00
kevin a7d81d42ea Signed-off-by: 无闻风 <wuwenfengmi@gmail.com> 2026-06-26 20:32:49 +08:00
kevin 85bcdfccaa Signed-off-by: 无闻风 <wuwenfengmi@gmail.com>
修改图标与应用名字
2026-06-26 20:20:52 +08:00
kevin 388c0dcfc5 Signed-off-by: 无闻风 <wuwenfengmi@gmail.com> 2026-06-26 20:02:08 +08:00
kevin 57c5362da3 fix: 物品打印模板客户移至左侧二维码下方 2026-06-26 19:57:06 +08:00
kevin b691545ad4 Signed-off-by: 无闻风 <wuwenfengmi@gmail.com> 2026-06-26 19:53:46 +08:00
kevin 6bc653bb74 feat: 重构工单和物品打印模板为左右排布,支持二维码和条码
- 工单模板:左侧二维码+客户,右侧工单ID/标题/描述
- 物品模板:左侧二维码,右侧物品ID/名称/数量/位置/客户,底部条码+序列号
- 打印标签设置:新增标签宽度/高度/打印前回退长度设置
- QR码JSON增加type=ops_sys字段区分系统来源
- WorkOrder/Item模型增加linkedCustomers等关联数据
- 使用Canvas绘制Bitmap实现自定义打印布局
2026-06-26 19:52:52 +08:00
kevin ae07e12200 up 2026-06-26 17:23:48 +08:00
kevin 7a9b3db3ca fix: 修复每次打印后关闭设备导致二次打印报设备未打开
移除 PrintHelper/PrintJob/PrinterManager/PrinterTestScreen 中每次打印后的 closeDevice() 调用,
设备生命周期由 MainActivity 管理(onStart 打开,onDestroy 关闭),避免反复 close->open 导致 SDK open() 失败。
2026-06-26 17:22:48 +08:00
kevin 71692a509e Signed-off-by: 无闻风 <wuwenfengmi@gmail.com>
up
2026-06-26 15:45:25 +08:00
kevin 627099159f feat: 添加打印机黑标检测开关和走纸长度设置
- 支持黑标检测开关,关闭时用走纸长度替代

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

- SessionManager 持久化打印机配置

- 新增 PrinterTestScreen 测试页面

- VERSION_CODE 82 -> 90
2026-06-26 15:43:57 +08:00
kevin c493dbc31c fix: 修复多个API通信和数据展示问题
- 修复仓库容器parent_id/物品container_id为null导致Gson反序列化失败
- 修复ScheduleApi参数名与后端不匹配
- 修复Profile页面username空字符串导致显示未登录
- 修复Retrofit Map类型参数编译后变为通配符被拒绝
- 修复ScrollableTabRow动态tab数量变化时IndexOutOfBounds崩溃
- 修复仓库新建容器字段名name→title匹配后端
- 修复新建物品传入错误的containerId
- 所有页面改为下拉刷新,去除首页自动刷新
- 新建工单支持关联物品/客户/图片上传
- 新增物品表单包含数量/关联客户/图片上传
- 物品详情页重构:基本信息/图片/关联工单/客户/移动历史/移动/删除/新建工单预填
- 打印模块添加黑标检测、Mutex并发锁、delay替换Thread.sleep
2026-06-25 15:43:56 +08:00
72 changed files with 6617 additions and 372 deletions
+11
View File
@@ -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>
File diff suppressed because it is too large Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
+10
View File
@@ -36,6 +36,16 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
@@ -20,6 +20,7 @@ class AppContainer(context: Context) {
val warehouseApi by lazy { apiServiceFactory.create<WarehouseApi>() }
val customerApi by lazy { apiServiceFactory.create<CustomerApi>() }
val scheduleApi by lazy { apiServiceFactory.create<ScheduleApi>() }
val fileApi by lazy { apiServiceFactory.create<FileApi>() }
val authRepository by lazy { AuthRepository(userApi, sessionManager) }
val orderRepository by lazy { OrderRepository(purchaseApi) }
@@ -5,6 +5,7 @@ import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.example.ops_android.data.model.Cookie
@@ -25,6 +26,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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

+1 -1
View File
@@ -1,3 +1,3 @@
<resources>
<string name="app_name">ops_android</string>
<string name="app_name">Operations 2</string>
</resources>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path name="photos" path="photos/" />
</paths>
+2 -2
View File
@@ -1,3 +1,3 @@
#Thu Jun 25 11:21:25 CST 2026
VERSION_CODE=48
#Fri Jun 26 20:46:06 CST 2026
VERSION_CODE=121
VERSION_NAME=1.2
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB