diff --git a/.idea/misc.xml b/.idea/misc.xml index a292fc4b..e5f25e13 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -25,6 +25,53 @@ + + + + + diff --git a/app/build.gradle b/app/build.gradle index e1a0e0c8..acab1ee3 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -15,7 +15,7 @@ android { //noinspection ExpiredTargetSdkVersion targetSdk 29 // 超过29后,文件权限要大改 versionCode 4 - versionName '1.21.0-release' + versionName '1.21.3-release' manifestPlaceholders = [ JPUSH_PKGNAME : applicationId, JPUSH_APPKEY : "599fe3f0f0f55d42155fae05", //JPush上注册的包名对应的Appkey,与包名相关,如果修改package name,需要自行注册极光推送账号,并修改这个选项 @@ -73,16 +73,22 @@ dependencies { implementation 'androidx.legacy:legacy-support-v4:1.0.0' implementation 'androidx.preference:preference:1.2.1' implementation 'androidx.activity:activity:1.9.3' + implementation 'androidx.biometric:biometric:1.2.0-alpha05' //生物识别 + implementation 'androidx.cardview:cardview:1.0.0' + implementation 'androidx.viewpager2:viewpager2:1.1.0' // 其他类库 implementation 'commons-io:commons-io:2.16.1' // 文件与路径处理 implementation 'com.github.getActivity:XXPermissions:18.2' // 权限控制 implementation files('libs/LPAPI-2024-10-21-R.jar') // 德佟打印机 implementation 'com.github.REBOOTERS:Matisse:v0.6.0' // 图片选择 implementation 'com.github.bumptech.glide:glide:4.16.0' // matisse依赖 + implementation 'io.getstream:photoview:1.0.3' // 图片预览缩放 implementation 'com.github.jenly1314:zxing-lite:3.1.1' // 二维码识别:扫描 implementation 'com.github.jenly1314:camera-scan:1.1.1' // 二维码识别:识别 implementation 'com.github.david-serrano:locationprovider:1.2' // 读取GPS implementation 'com.alibaba:fastjson:2.0.49' // JSON处理 + compileOnly 'org.projectlombok:lombok:1.18.34' // 简化离线填报模型类模板代码 + annotationProcessor 'org.projectlombok:lombok:1.18.34' implementation 'com.github.DImuthuUpe:AndroidPdfViewer:3.1.0-beta.1' // 预览PDF implementation 'cn.jiguang.sdk:jpush:5.3.1' // 极光推送 implementation 'com.google.firebase:firebase-messaging:24.1.0' // 极光推送 @@ -93,5 +99,4 @@ dependencies { implementation 'com.vinaygaba:rubberstamp:1.0.0' // 图片水印 implementation 'com.github.DantSu:ESCPOS-ThermalPrinter-Android:3.3.0' // ECS蓝牙打印机 implementation 'com.github.zhaolewei:ZlwAudioRecorder:v1.08' // 录音机 - implementation 'androidx.biometric:biometric:1.2.0-alpha05' // -} \ No newline at end of file +} diff --git a/app/release/output-metadata.json b/app/release/output-metadata.json index 04f18cb6..f3ab94a9 100644 --- a/app/release/output-metadata.json +++ b/app/release/output-metadata.json @@ -12,8 +12,8 @@ "filters": [], "attributes": [], "versionCode": 4, - "versionName": "1.20.2-release", - "outputFile": "hac-1.20.2-release.apk" + "versionName": "1.21.3-release", + "outputFile": "hac-1.21.3-release.apk" } ], "elementType": "File" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 69c9c325..9ac4d04f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -142,6 +142,22 @@ + + + + + + + + - \ No newline at end of file + diff --git a/app/src/main/java/com/huozige/lab/container/HACQRCodeScanActivity.java b/app/src/main/java/com/huozige/lab/container/HACQRCodeScanActivity.java index 84ef956f..17e41a2d 100644 --- a/app/src/main/java/com/huozige/lab/container/HACQRCodeScanActivity.java +++ b/app/src/main/java/com/huozige/lab/container/HACQRCodeScanActivity.java @@ -26,6 +26,8 @@ public class HACQRCodeScanActivity extends BarcodeCameraScanActivity { * 存放条码类型的Extra名称 */ public static final String EXTRA_KEY_SCAN_HINTS = "hints"; + // 允许不同扫码入口单独控制是否播放扫码提示音,默认保持原有的播放行为。 + public static final String EXTRA_KEY_PLAY_BEEP = "playBeep"; /** * 仅处理QR码,速度更快 */ @@ -43,8 +45,8 @@ protected void onResume(){ @Override public void initCameraScan(@NonNull CameraScan cameraScan) { super.initCameraScan(cameraScan); - // 根据需要设置CameraScan相关配置 - cameraScan.setPlayBeep(true); + // 根据调用入口传入的参数决定是否播放提示音;未传时默认播放。 + cameraScan.setPlayBeep(getIntent().getBooleanExtra(EXTRA_KEY_PLAY_BEEP, true)); } @Nullable diff --git a/app/src/main/java/com/huozige/lab/container/MainActivity.java b/app/src/main/java/com/huozige/lab/container/MainActivity.java index ab05b813..7710ef61 100644 --- a/app/src/main/java/com/huozige/lab/container/MainActivity.java +++ b/app/src/main/java/com/huozige/lab/container/MainActivity.java @@ -18,6 +18,7 @@ import com.elvishew.xlog.XLog; import com.hjq.permissions.Permission; +import com.huozige.lab.container.offlineform.OfflinePlusListActivity; import com.huozige.lab.container.platform.AbstractStaticFilesCacheFilter; import com.huozige.lab.container.platform.AbstractWebInterop; import com.huozige.lab.container.platform.hzg.HZGCacheFilter; @@ -61,6 +62,7 @@ public class MainActivity extends BaseActivity { static final int MENU_ID_SETTINGS = 2; static final int MENU_ID_HELP = 3; static final int MENU_ID_ABOUT = 4; + static final int MENU_ID_OFFLINE_PLUS = 5; @SuppressLint({"JavascriptInterface", "SetJavaScriptEnabled"}) @Override @@ -250,6 +252,11 @@ public boolean onCreateOptionsMenu(Menu menu) { menu.add(0, MENU_ID_SETTINGS, MENU_ID_SETTINGS, getString(R.string.ui_menu_settings)); } + // 超级离线模式 + if (ConfigManager.getInstance().getOfflinePlusMenuVisible()) { + menu.add(0, MENU_ID_OFFLINE_PLUS, MENU_ID_OFFLINE_PLUS, getString(R.string.ui_menu_offlineplus)); + } + // 帮助 if (!ConfigManager.getInstance().getHelpUrl().isEmpty()) { menu.add(0, MENU_ID_HELP, MENU_ID_HELP, getString(R.string.ui_menu_help)); @@ -332,6 +339,14 @@ public boolean onOptionsItemSelected(MenuItem item) { _webView.loadUrl(ConfigManager.getInstance().getAboutUrl()); } break; + case MENU_ID_OFFLINE_PLUS: + if (DeviceUtility.isOfflineMode()) { + // 普通离线模式不允许执行后退操作 + Toast.makeText(this, "应用处于普通离线模式,无法打开【离线填报入口】页面。", Toast.LENGTH_LONG).show(); + } else { + startActivity(new Intent(this, OfflinePlusListActivity.class)); + } + break; // 你可以在这里处理新创建菜单的点击事件 } @@ -361,4 +376,4 @@ protected void onActivityResult(int requestCode, int resultCode, Intent data) { } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/huozige/lab/container/OptionSettingsActivity.java b/app/src/main/java/com/huozige/lab/container/OptionSettingsActivity.java index 44bcabe9..87bf3342 100644 --- a/app/src/main/java/com/huozige/lab/container/OptionSettingsActivity.java +++ b/app/src/main/java/com/huozige/lab/container/OptionSettingsActivity.java @@ -12,15 +12,21 @@ import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AppCompatActivity; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; import androidx.preference.PreferenceDataStore; import androidx.preference.PreferenceFragmentCompat; import androidx.preference.PreferenceManager; import com.elvishew.xlog.XLog; +import com.hjq.permissions.Permission; import com.huozige.lab.container.utilities.ConfigManager; import com.huozige.lab.container.utilities.DeviceUtility; import com.huozige.lab.container.utilities.LifecycleUtility; +import com.huozige.lab.container.utilities.PermissionsUtility; import com.huozige.lab.container.utilities.UiUtility; +import com.huozige.lab.container.preferences.ScanEditTextPreference; +import com.king.camera.scan.CameraScan; import javax.annotation.Nullable; @@ -143,6 +149,26 @@ public boolean onOptionsItemSelected(MenuItem item) { */ public static class SettingsFragment extends PreferenceFragmentCompat { + private ScanEditTextPreference _entryPreference; + + // 首页地址扫码页返回后先让用户确认,确认后才写入设置项。 + private final ActivityResultLauncher _arcZxingLite = registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), result -> { + Intent data = result.getData(); + if (data == null || _entryPreference == null) { + return; + } + + String url = CameraScan.parseScanResult(data); + new AlertDialog.Builder(requireActivity()) + .setTitle(R.string.url_title) + .setMessage(url) + .setPositiveButton(R.string.ui_button_ok, (dialogInterface, i) -> _entryPreference.setText(url)) + .setNegativeButton(R.string.ui_button_cancel, (dialogInterface, i) -> { + // 什么都不干 + }) + .show(); + }); + @Override public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { // 对接ConfigManager实现数据存取 @@ -151,6 +177,23 @@ public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { // 默认逻辑 setPreferencesFromResource(R.xml.root_preferences, rootKey); + + // 只给首页地址(key=E)绑定扫码按钮,其他设置项仍保持普通输入行为。 + _entryPreference = findPreference(ConfigManager.PREFERENCE_KEY_APP_ENTRY_URL); + if (_entryPreference != null) { + _entryPreference.setScanClickListener(view -> { + PermissionsUtility.asyncRequirePermissions(requireActivity(), new String[]{ + Permission.CAMERA, + Permission.NOTIFICATION_SERVICE + }, () -> { + Intent intent = new Intent(requireActivity(), HACQRCodeScanActivity.class); + intent.putExtra(HACQRCodeScanActivity.EXTRA_KEY_SCAN_HINTS, HACQRCodeScanActivity.EXTRA_QR_CODE_HINTS); + // 这里是手动录入首页地址的辅助扫码入口,不播放提示音,避免设置页里突然响一声。 + intent.putExtra(HACQRCodeScanActivity.EXTRA_KEY_PLAY_BEEP, false); + _arcZxingLite.launch(intent); + }); + }); + } } } @@ -183,4 +226,4 @@ public String getString(String key, @Nullable String defValue) { return ConfigManager.getInstance().getStringValue(key, defValue); } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/CustomFormActivity.java b/app/src/main/java/com/huozige/lab/container/offlineform/CustomFormActivity.java new file mode 100644 index 00000000..b065c68a --- /dev/null +++ b/app/src/main/java/com/huozige/lab/container/offlineform/CustomFormActivity.java @@ -0,0 +1,991 @@ +package com.huozige.lab.container.offlineform; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.ValueAnimator; +import android.content.Intent; +import android.graphics.drawable.ColorDrawable; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.GradientDrawable; +import android.net.Uri; +import android.os.Bundle; +import android.text.InputType; +import android.view.Gravity; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.AdapterView; +import android.widget.ArrayAdapter; +import android.widget.EditText; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.Spinner; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.widget.NestedScrollView; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.google.android.material.tabs.TabLayout; +import com.huozige.lab.container.R; +import com.huozige.lab.container.offlineform.formitem.AttachmentCallback; +import com.huozige.lab.container.offlineform.formitem.OfflineFormItemValueHelper; +import com.huozige.lab.container.offlineform.formitem.file.FileUploadHost; +import com.huozige.lab.container.offlineform.formitem.file.OfflineFileHelper; +import com.huozige.lab.container.offlineform.formitem.image.ImageCaptureHost; +import com.huozige.lab.container.offlineform.formitem.image.OfflineImagePreviewActivity; +import com.huozige.lab.container.offlineform.formitem.image.OfflineImageFileHelper; +import com.huozige.lab.container.offlineform.model.OfflineFormDefinition; +import com.huozige.lab.container.offlineform.model.OfflineFormDefinitionFlattener; +import com.huozige.lab.container.offlineform.model.OfflineFormDefinitionFile; +import com.huozige.lab.container.offlineform.model.OfflineFormDisplayItem; +import com.huozige.lab.container.offlineform.model.OfflineFormNode; +import com.huozige.lab.container.offlineform.model.OfflineFormRecord; +import com.huozige.lab.container.offlineform.model.OfflineFormRecordStatus; +import com.huozige.lab.container.offlineform.model.OfflineFormStep; +import com.huozige.lab.container.offlineform.model.formitem.common.AttachmentFormItemValue; +import com.huozige.lab.container.offlineform.model.formitem.common.BaseFormItem; +import com.huozige.lab.container.offlineform.model.formitem.file.FileFormItem; +import com.huozige.lab.container.offlineform.model.formitem.image.ImageFormItem; +import com.huozige.lab.container.offlineform.model.formitem.picker.PickerFormItem; +import com.huozige.lab.container.offlineform.model.formitem.select.SelectFormItem; +import com.huozige.lab.container.offlineform.model.formitem.text.TextFormItem; +import com.huozige.lab.container.proxy.support.capture.CameraViewActivity; +import com.huozige.lab.container.proxy.support.offlinecustomform.FormAdapter; +import com.huozige.lab.container.proxy.support.offlinecustomform.helper.OfflineFormFileHelper; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import static com.huozige.lab.container.offlineform.util.OfflineFormUiUnitHelper.dp; + +public class CustomFormActivity extends AppCompatActivity implements ImageCaptureHost, FileUploadHost { + public static final String EXTRA_RECORD_ID = "recordId"; + private static final int MENU_ID_SAVE_RECORD = 1; + private static final int FILTER_ALL = 0; + private static final int FILTER_UNFILLED = 1; + private static final int FILTER_FILLED = 2; + private static final int FILTER_SEARCH = 3; + + private RecyclerView _recyclerView; + private NestedScrollView _formScrollView; + private TabLayout _stepTabLayout; + private LinearLayout _itemNavigationLayout; + private LinearLayout _searchKeywordLayout; + private TextView _previousItemButton; + private TextView _nextItemButton; + private TextView _bottomHintTextView; + private TextView _searchKeywordTextView; + private ImageView _clearSearchButton; + private Spinner _filterModeSpinner; + private FormAdapter _adapter; + private Intent _intent; + private boolean _formLoaded; + private boolean _editRecordMissingOrOutdated; + private boolean _updatingTabs; + private AdapterView.OnItemSelectedListener _filterModeSelectedListener; + private int _filterMode = FILTER_ALL; + private int _filterModeBeforeSearch = FILTER_ALL; + private String _searchKeyword = ""; + private OfflineFormRecord _editingRecord; + private OfflineFormDefinition _definition; + private int _currentStepIndex; + private ActivityResultLauncher _imageCaptureLauncher; + private ActivityResultLauncher _imageUploadLauncher; + private ActivityResultLauncher _imagePreviewLauncher; + private ActivityResultLauncher _fileUploadLauncher; + private ImageFormItem _pendingImageItem; + private AttachmentCallback _pendingImageCallback; + private FileFormItem _pendingFileItem; + private AttachmentCallback _pendingFileCallback; + + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.offline_form_custom_form_activity); + + _intent = getIntent(); + + initViews(); + setupRecyclerView(); + registerImageCaptureLauncher(); + registerImageUploadLauncher(); + registerImagePreviewLauncher(); + registerFileUploadLauncher(); + loadFormDataFromJson(); + setupListeners(); + } + + private void initViews() { + _recyclerView = findViewById(R.id.recycler_view); + _formScrollView = findViewById(R.id.scroll_form); + _stepTabLayout = findViewById(R.id.tab_steps); + _itemNavigationLayout = findViewById(R.id.layout_item_navigation); + _searchKeywordLayout = findViewById(R.id.layout_search_keyword); + _previousItemButton = findViewById(R.id.button_previous_item); + _nextItemButton = findViewById(R.id.button_next_item); + _bottomHintTextView = findViewById(R.id.text_form_bottom_hint); + _searchKeywordTextView = findViewById(R.id.text_search_keyword); + _clearSearchButton = findViewById(R.id.button_clear_search); + _filterModeSpinner = findViewById(R.id.spinner_filter_mode); + setupClearSearchButtonBackground(); + setupBottomHintHeight(); + } + + private void setupClearSearchButtonBackground() { + GradientDrawable background = new GradientDrawable(); + background.setShape(GradientDrawable.OVAL); + background.setColor(getColor(R.color.gray)); + _clearSearchButton.setBackground(background); + } + + private void setupRecyclerView() { + _adapter = new FormAdapter(); + _recyclerView.setLayoutManager(new LinearLayoutManager(this)); + _recyclerView.setAdapter(_adapter); + } + + private void setupBottomHintHeight() { + int height = (int) (getResources().getDisplayMetrics().heightPixels * 0.35f); + ViewGroup.LayoutParams params = _bottomHintTextView.getLayoutParams(); + params.height = height; + _bottomHintTextView.setLayoutParams(params); + } + + private void registerImageCaptureLauncher() { + _imageCaptureLauncher = registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), result -> { + if (result.getResultCode() != RESULT_OK || result.getData() == null || _pendingImageItem == null || _pendingImageCallback == null) { + clearPendingImageCapture(); + return; + } + + String uriText = result.getData().getStringExtra(CameraViewActivity.EXTRA_OUT_URI); + if (uriText == null || uriText.isEmpty()) { + clearPendingImageCapture(); + return; + } + + try { + OfflineFormRecord draft = ensureDraftRecordForAttachment(); + List images = new ArrayList<>(); + images.add(OfflineImageFileHelper.saveImage(this, draft.getPatternId(), _pendingImageItem, Uri.parse(uriText))); + _pendingImageCallback.onAttachmentsSelected(images); + saveDraftIfNeeded(); + } catch (Exception e) { + e.printStackTrace(); + Toast.makeText(this, getString(R.string.offline_toast_image_save_failed, e.getMessage()), Toast.LENGTH_LONG).show(); + } finally { + clearPendingImageCapture(); + } + }); + } + + private void registerImageUploadLauncher() { + _imageUploadLauncher = registerForActivityResult(new ActivityResultContracts.GetMultipleContents(), uris -> { + if (uris == null || uris.isEmpty() || _pendingImageItem == null || _pendingImageCallback == null) { + clearPendingImageCapture(); + return; + } + + try { + OfflineFormRecord draft = ensureDraftRecordForAttachment(); + List images = new ArrayList<>(); + int remainingCount = getRemainingImageCount(_pendingImageItem); + for (Uri uri : uris) { + if (images.size() >= remainingCount) { + break; + } + images.add(OfflineImageFileHelper.saveImage(this, draft.getPatternId(), _pendingImageItem, uri)); + } + _pendingImageCallback.onAttachmentsSelected(images); + if (uris.size() > images.size()) { + Toast.makeText(this, getString(R.string.offline_toast_image_max_count_reached, _pendingImageItem.getMaxCount()), Toast.LENGTH_SHORT).show(); + } + saveDraftIfNeeded(); + } catch (Exception e) { + e.printStackTrace(); + Toast.makeText(this, getString(R.string.offline_toast_image_save_failed, e.getMessage()), Toast.LENGTH_LONG).show(); + } finally { + clearPendingImageCapture(); + } + }); + } + + private void registerImagePreviewLauncher() { + _imagePreviewLauncher = registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), result -> { + if (_pendingImageItem == null || result.getResultCode() != RESULT_OK || result.getData() == null) { + _pendingImageItem = null; + return; + } + ArrayList fileNames = result.getData().getStringArrayListExtra(OfflineImagePreviewActivity.EXTRA_FILE_NAMES); + if (fileNames == null) { + _pendingImageItem = null; + return; + } + _pendingImageItem.setImagesFromFileNames(fileNames); + saveDraftIfNeeded(); + _adapter.notifyDataSetChanged(); + _pendingImageItem = null; + }); + } + + private void registerFileUploadLauncher() { + _fileUploadLauncher = registerForActivityResult(new ActivityResultContracts.OpenMultipleDocuments(), uris -> { + if (uris == null || uris.isEmpty() || _pendingFileItem == null || _pendingFileCallback == null) { + clearPendingFileUpload(); + return; + } + + try { + OfflineFormRecord draft = ensureDraftRecordForAttachment(); + List files = new ArrayList<>(); + int remainingCount = getRemainingFileCount(_pendingFileItem); + for (Uri uri : uris) { + if (files.size() >= remainingCount) { + break; + } + String originalName = OfflineFileHelper.getDisplayName(this, uri); + if (_pendingFileItem.containsOriginalName(originalName) || containsOriginalName(files, originalName)) { + Toast.makeText(this, getString(R.string.offline_toast_duplicate_file, originalName), Toast.LENGTH_SHORT).show(); + continue; + } + files.add(OfflineFileHelper.saveFile(this, draft.getPatternId(), _pendingFileItem, uri)); + } + _pendingFileCallback.onAttachmentsSelected(files); + if (uris.size() > files.size()) { + Toast.makeText(this, R.string.offline_toast_file_upload_partial, Toast.LENGTH_SHORT).show(); + } + saveDraftIfNeeded(); + } catch (Exception e) { + e.printStackTrace(); + Toast.makeText(this, getString(R.string.offline_toast_file_save_failed, e.getMessage()), Toast.LENGTH_LONG).show(); + } finally { + clearPendingFileUpload(); + } + }); + } + + private void loadFormDataFromJson() { + + String patternId = _intent.getStringExtra("patternId"); + if (patternId == null || patternId.isEmpty()) { + Toast.makeText(this, R.string.offline_toast_config_missing, Toast.LENGTH_SHORT).show(); + _adapter.setDisplayItems(new ArrayList<>()); + return; + } + + try { + OfflineFormDefinitionFile definitionFile = OfflineFormFileHelper.readDefinition(this, patternId); + if (definitionFile != null && patternId.equals(definitionFile.getJsonSchema().getPatternId())) { + _definition = definitionFile.getJsonSchema(); + List formItems = OfflineFormDefinitionFlattener.flattenFields(_definition); + applyPatternIdToAttachmentItems(formItems, patternId); + loadEditingRecord(patternId, _definition.getSchemaVersion(), formItems); + } + } catch (Exception e) { + e.printStackTrace(); + Toast.makeText(this, getString(R.string.offline_toast_parse_form_failed, e.getMessage()), Toast.LENGTH_LONG).show(); + } + _formLoaded = _definition != null && _definition.getSteps() != null && !_definition.getSteps().isEmpty(); + renderCurrentStep(); + } + + private void setupListeners() { + _previousItemButton.setOnClickListener(v -> navigateRootItem(-1)); + _nextItemButton.setOnClickListener(v -> navigateRootItem(1)); + _searchKeywordLayout.setOnClickListener(v -> clearSearchFilter()); + setupFilterModeSpinner(); + _stepTabLayout.addOnTabSelectedListener(new TabLayout.OnTabSelectedListener() { + @Override + public void onTabSelected(TabLayout.Tab tab) { + if (_updatingTabs || !_formLoaded || tab.getPosition() == _currentStepIndex) { + return; + } + saveDraftIfNeeded(); + _currentStepIndex = tab.getPosition(); + resetFilterMode(); + renderCurrentStep(); + _recyclerView.scrollToPosition(0); + _formScrollView.scrollTo(0, 0); + } + + @Override + public void onTabUnselected(TabLayout.Tab tab) { + } + + @Override + public void onTabReselected(TabLayout.Tab tab) { + } + }); + } + + private void setupFilterModeSpinner() { + ArrayAdapter adapter = new ArrayAdapter<>(this, android.R.layout.simple_spinner_item, new String[]{ + getString(R.string.offline_filter_all), + getString(R.string.offline_filter_unfilled), + getString(R.string.offline_filter_filled), + getString(R.string.offline_filter_search) + }); + adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + _filterModeSpinner.setAdapter(adapter); + _filterModeSpinner.setSelection(FILTER_ALL); + _filterModeSelectedListener = new AdapterView.OnItemSelectedListener() { + @Override + public void onItemSelected(AdapterView parent, View view, int position, long id) { + if (position == FILTER_SEARCH) { + if (_filterMode != FILTER_SEARCH) { + _filterModeBeforeSearch = _filterMode; + } + showSearchDialog(); + return; + } + _searchKeyword = ""; + _filterMode = position; + renderCurrentStep(); + } + + @Override + public void onNothingSelected(AdapterView parent) { + } + }; + _filterModeSpinner.setOnItemSelectedListener(_filterModeSelectedListener); + } + + private void resetFilterMode() { + _filterModeSpinner.setOnItemSelectedListener(null); + _filterMode = FILTER_ALL; + _filterModeBeforeSearch = FILTER_ALL; + _searchKeyword = ""; + _filterModeSpinner.setSelection(FILTER_ALL); + _filterModeSpinner.setOnItemSelectedListener(_filterModeSelectedListener); + } + + private void showSearchDialog() { + LinearLayout container = new LinearLayout(this); + container.setPadding(dp(this, 24), 0, dp(this, 24), 0); + + EditText input = new EditText(this); + input.setSingleLine(true); + input.setInputType(InputType.TYPE_CLASS_TEXT); + input.setHint(R.string.offline_hint_field_search); + input.setText(_searchKeyword); + input.setSelectAllOnFocus(true); + container.addView(input, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT)); + + new AlertDialog.Builder(this) + .setTitle(R.string.offline_dialog_search_title) + .setView(container) + .setPositiveButton(R.string.offline_button_confirm, (dialog, which) -> applySearchFilter(input.getText().toString())) + .setNegativeButton(R.string.offline_button_cancel, (dialog, which) -> restoreFilterModeBeforeSearch()) + .setOnCancelListener(dialog -> restoreFilterModeBeforeSearch()) + .show(); + } + + private void applySearchFilter(String keyword) { + _searchKeyword = keyword == null ? "" : keyword.trim(); + if (_searchKeyword.isEmpty()) { + clearSearchFilter(); + return; + } + _filterModeBeforeSearch = _filterMode == FILTER_SEARCH ? _filterModeBeforeSearch : _filterMode; + _filterMode = FILTER_SEARCH; + setFilterModeSelection(FILTER_SEARCH); + renderCurrentStep(); + } + + private void clearSearchFilter() { + _searchKeyword = ""; + _filterMode = FILTER_ALL; + _filterModeBeforeSearch = FILTER_ALL; + setFilterModeSelection(FILTER_ALL); + renderCurrentStep(); + } + + private void restoreFilterModeBeforeSearch() { + _filterMode = _filterModeBeforeSearch; + setFilterModeSelection(_filterMode); + } + + private void setFilterModeSelection(int filterMode) { + _filterModeSpinner.setOnItemSelectedListener(null); + _filterModeSpinner.setSelection(filterMode); + _filterModeSpinner.setOnItemSelectedListener(_filterModeSelectedListener); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + menu.add(0, MENU_ID_SAVE_RECORD, MENU_ID_SAVE_RECORD, R.string.offline_button_save) + .setActionView(createSaveActionView()) + .setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS); + return true; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (item.getItemId() == MENU_ID_SAVE_RECORD) { + onSaveMenuClick(); + return true; + } + return super.onOptionsItemSelected(item); + } + + private View createSaveActionView() { + LinearLayout actionView = new LinearLayout(this); + actionView.setOrientation(LinearLayout.HORIZONTAL); + actionView.setGravity(Gravity.CENTER); + actionView.setPadding(dp(this, 10), 0, dp(this, 12), 0); + actionView.setMinimumHeight(dp(this, 48)); + actionView.setClickable(true); + actionView.setOnClickListener(v -> onSaveMenuClick()); + + ImageView iconView = new ImageView(this); + iconView.setImageResource(R.drawable.ic_offline_save); + LinearLayout.LayoutParams iconParams = new LinearLayout.LayoutParams(dp(this, 20), dp(this, 20)); + iconParams.setMargins(0, 0, dp(this, 4), 0); + actionView.addView(iconView, iconParams); + + TextView textView = new TextView(this); + textView.setText(R.string.offline_button_save); + textView.setTextColor(getColor(R.color.white)); + textView.setTextSize(16); + textView.setGravity(Gravity.CENTER); + actionView.addView(textView, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT)); + + return actionView; + } + + private void onSaveMenuClick() { + if (!_formLoaded) { + Toast.makeText(this, R.string.offline_toast_config_missing, Toast.LENGTH_SHORT).show(); + return; + } + if (_editRecordMissingOrOutdated) { + Toast.makeText(this, R.string.offline_toast_old_record_edit_not_supported, Toast.LENGTH_SHORT).show(); + return; + } + if (!validateAllSteps()) { + Toast.makeText(this, R.string.offline_toast_check_form_input, Toast.LENGTH_SHORT).show(); + scrollToFirstError(); + return; + } + submit(); + } + + private void submit() { + Map formData = collectAllFormData(); + String patternId = _intent.getStringExtra("patternId"); + String schemaVersion = _intent.getStringExtra("schemaVersion"); + OfflineFormRecord record; + if (_editingRecord == null) { + record = OfflineFormRecord.createSubmitted(patternId, schemaVersion, formData); + } else { + _editingRecord.setValues(formData); + _editingRecord.setStatus(OfflineFormRecordStatus.SUBMITTED); + _editingRecord.setUpdatedAt(System.currentTimeMillis()); + record = _editingRecord; + } + // 离线填报数据按表单定义目录保存,删除表单定义时可直接清理对应文件夹。 + OfflineFormFileHelper.writeRecord(this, record); + + + Toast.makeText(this, R.string.offline_toast_record_saved, Toast.LENGTH_SHORT).show(); + finish(); + } + + private void renderCurrentStep() { + if (!_formLoaded) { + _adapter.setDisplayItems(new ArrayList<>()); + _stepTabLayout.setVisibility(View.GONE); + _itemNavigationLayout.setVisibility(View.GONE); + return; + } + + renderStepTabs(); + renderSearchKeyword(); + OfflineFormStep step = _definition.getSteps().get(_currentStepIndex); + _adapter.setDisplayItems(buildCurrentStepDisplayItems(step)); + updateItemNavigationVisibility(); + if (step.getTitle() != null && !step.getTitle().isEmpty()) { + setTitle(step.getTitle()); + } + } + + private List buildCurrentStepDisplayItems(OfflineFormStep step) { + List displayItems = OfflineFormDefinitionFlattener.flattenStep(step); + if (_filterMode == FILTER_ALL) { + return displayItems; + } + + List result = new ArrayList<>(); + int skipDepth = -1; + for (OfflineFormDisplayItem item : displayItems) { + if (skipDepth >= 0) { + if (item.getDepth() > skipDepth) { + continue; + } + skipDepth = -1; + } + + if (item.getDepth() != 0) { + result.add(item); + continue; + } + if (shouldShowRootItemByFilter(item)) { + result.add(item); + } else if (item.isGroup()) { + skipDepth = item.getDepth(); + } + } + return result; + } + + private boolean shouldShowRootItemByFilter(OfflineFormDisplayItem item) { + switch (_filterMode) { + case FILTER_UNFILLED: + return item.isField() + ? item.getFormItem().isEmpty() + : item.isGroup() && hasEmptyField(item.getNode()); + case FILTER_FILLED: + return item.isField() + ? !item.getFormItem().isEmpty() + : item.isGroup() && hasField(item.getNode()) && !hasEmptyField(item.getNode()); + case FILTER_SEARCH: + return matchesSearch(item); + case FILTER_ALL: + default: + return true; + } + } + + private void renderSearchKeyword() { + boolean searching = _filterMode == FILTER_SEARCH && !_searchKeyword.isEmpty(); + _searchKeywordLayout.setVisibility(searching ? View.VISIBLE : View.GONE); + _filterModeSpinner.setVisibility(searching ? View.GONE : View.VISIBLE); + if (searching) { + _searchKeywordTextView.setText(getString(R.string.offline_text_search_keyword, _searchKeyword)); + } + } + + private boolean matchesSearch(OfflineFormDisplayItem item) { + if (_searchKeyword.isEmpty()) { + return true; + } + if (item.isField()) { + return containsSearchText(item.getNode()) || containsSearchText(item.getFormItem().getTitle()); + } + return containsSearchText(item.getNode()) || hasSearchText(item.getNode()); + } + + private boolean hasSearchText(OfflineFormNode node) { + if (node == null || node.getChildren() == null) { + return false; + } + for (OfflineFormNode child : node.getChildren()) { + if (containsSearchText(child)) { + return true; + } + if (child.getField() != null && containsSearchText(child.getField().getTitle())) { + return true; + } + if (hasSearchText(child)) { + return true; + } + } + return false; + } + + private boolean containsSearchText(OfflineFormNode node) { + if (node == null) { + return false; + } + return containsSearchText(node.getTitle()) || containsSearchText(node.getContent()); + } + + private boolean containsSearchText(String text) { + return text != null && text.toLowerCase(Locale.CHINA).contains(_searchKeyword.toLowerCase(Locale.CHINA)); + } + + private boolean hasEmptyField(OfflineFormNode node) { + if (node == null || node.getChildren() == null) { + return false; + } + for (OfflineFormNode child : node.getChildren()) { + if (OfflineFormNode.TYPE_FIELD.equals(child.getNodeType()) && child.getField() != null && child.getField().isEmpty()) { + return true; + } + if (OfflineFormNode.TYPE_GROUP.equals(child.getNodeType()) && hasEmptyField(child)) { + return true; + } + } + return false; + } + + private boolean hasField(OfflineFormNode node) { + if (node == null || node.getChildren() == null) { + return false; + } + for (OfflineFormNode child : node.getChildren()) { + if (OfflineFormNode.TYPE_FIELD.equals(child.getNodeType())) { + return true; + } + if (OfflineFormNode.TYPE_GROUP.equals(child.getNodeType()) && hasField(child)) { + return true; + } + } + return false; + } + + private void navigateRootItem(int direction) { + int targetPosition = findRootItemPosition(direction); + if (targetPosition < 0) { + return; + } + scrollToRootItem(targetPosition); + } + + private int findRootItemPosition(int direction) { + int itemCount = _adapter.getItemCount(); + int anchorY = getCurrentContentScrollY() + dp(this, direction > 0 ? 16 : -16); + int start = direction > 0 ? 0 : itemCount - 1; + int end = direction > 0 ? itemCount : -1; + for (int i = start; i != end; i += direction > 0 ? 1 : -1) { + View itemView = getRootItemView(i); + if (itemView != null && (direction > 0 ? itemView.getTop() > anchorY : itemView.getTop() < anchorY)) { + return i; + } + } + return -1; + } + + private int getCurrentContentScrollY() { + return _formScrollView.getScrollY() - _recyclerView.getTop(); + } + + private void scrollToRootItem(int position) { + _recyclerView.scrollToPosition(position); + _recyclerView.postDelayed(() -> { + View itemView = getRootItemView(position); + if (itemView == null) { + return; + } + int scrollY = Math.max(0, _recyclerView.getTop() + itemView.getTop() - dp(this, 8)); + _formScrollView.smoothScrollTo(0, scrollY); + playHighlight(itemView); + }, 80); + } + + private View getRootItemView(int position) { + RecyclerView.ViewHolder viewHolder = _recyclerView.findViewHolderForAdapterPosition(position); + return viewHolder == null ? null : viewHolder.itemView; + } + + private void updateItemNavigationVisibility() { + int itemCount = _adapter == null ? 0 : _adapter.getItemCount(); + _itemNavigationLayout.setVisibility(_formLoaded && itemCount > 1 ? View.VISIBLE : View.GONE); + } + + private void playHighlight(View itemView) { + Drawable originalForeground = itemView.getForeground(); + ColorDrawable highlight = new ColorDrawable(getColor(R.color.huozige_blue)); + itemView.setForeground(highlight); + + ValueAnimator animator = ValueAnimator.ofInt(72, 0); + animator.setDuration(650); + animator.addUpdateListener(animation -> highlight.setAlpha((int) animation.getAnimatedValue())); + animator.addListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + itemView.setForeground(originalForeground); + } + }); + animator.start(); + } + + private void renderStepTabs() { + _updatingTabs = true; + _stepTabLayout.setVisibility(_definition.getSteps().size() > 1 ? View.VISIBLE : View.GONE); + _stepTabLayout.removeAllTabs(); + for (int i = 0; i < _definition.getSteps().size(); i++) { + OfflineFormStep step = _definition.getSteps().get(i); + String title = step.getTitle() == null || step.getTitle().isEmpty() ? getString(R.string.offline_text_default_step, i + 1) : step.getTitle(); + _stepTabLayout.addTab(_stepTabLayout.newTab().setText(title), i == _currentStepIndex); + } + _updatingTabs = false; + } + + private Map collectAllFormData() { + Map formData = new HashMap<>(); + for (BaseFormItem item : OfflineFormDefinitionFlattener.flattenFields(_definition)) { + formData.put(item.getId(), item.getValue()); + } + return formData; + } + + private void saveDraftIfNeeded() { + if (_editingRecord != null && _editingRecord.getStatus() != OfflineFormRecordStatus.DRAFT) { + return; + } + if (!hasFieldBeforeNextStep()) { + return; + } + + Map formData = collectAllFormData(); + String patternId = _intent.getStringExtra("patternId"); + String schemaVersion = _intent.getStringExtra("schemaVersion"); + if (_editingRecord == null) { + _editingRecord = OfflineFormRecord.createDraft(patternId, schemaVersion, formData); + } else { + _editingRecord.setValues(formData); + _editingRecord.setStatus(OfflineFormRecordStatus.DRAFT); + _editingRecord.setUpdatedAt(System.currentTimeMillis()); + } + OfflineFormFileHelper.writeRecord(this, _editingRecord); + } + + private boolean validateAllSteps() { + int originalStepIndex = _currentStepIndex; + int originalFilterMode = _filterMode; + String originalSearchKeyword = _searchKeyword; + _filterMode = FILTER_ALL; + _searchKeyword = ""; + clearErrorsForEmptyValidationUnits(); + for (int i = 0; i < _definition.getSteps().size(); i++) { + OfflineFormStep step = _definition.getSteps().get(i); + boolean rendered = false; + for (List validationUnit : collectValidationUnits(step)) { + if (!hasFilledField(validationUnit)) { + continue; + } + if (!rendered) { + _currentStepIndex = i; + renderCurrentStep(); + rendered = true; + } + if (!_adapter.validateItems(validationUnit)) { + _searchKeyword = ""; + setFilterModeSelection(FILTER_ALL); + return false; + } + } + } + + _currentStepIndex = originalStepIndex; + _filterMode = originalFilterMode; + _searchKeyword = originalSearchKeyword; + setFilterModeSelection(_filterMode); + renderCurrentStep(); + return true; + } + + private List> collectValidationUnits(OfflineFormStep step) { + List displayItems = OfflineFormDefinitionFlattener.flattenStep(step); + List> validationUnits = new ArrayList<>(); + for (int i = 0; i < displayItems.size(); i++) { + OfflineFormDisplayItem displayItem = displayItems.get(i); + if (displayItem.getDepth() != 0) { + continue; + } + if (displayItem.isGroup()) { + List groupItems = new ArrayList<>(); + groupItems.add(displayItem); + int nextIndex = i + 1; + while (nextIndex < displayItems.size() && displayItems.get(nextIndex).getDepth() > 0) { + groupItems.add(displayItems.get(nextIndex)); + nextIndex++; + } + validationUnits.add(groupItems); + i = nextIndex - 1; + } else if (displayItem.isField()) { + List fieldItem = new ArrayList<>(); + fieldItem.add(displayItem); + validationUnits.add(fieldItem); + } + } + return validationUnits; + } + + private void clearErrorsForEmptyValidationUnits() { + for (OfflineFormStep step : _definition.getSteps()) { + for (List validationUnit : collectValidationUnits(step)) { + if (!hasFilledField(validationUnit)) { + clearValidationErrors(validationUnit); + } + } + } + } + + private boolean hasFilledField(List displayItems) { + for (OfflineFormDisplayItem displayItem : displayItems) { + if (displayItem.isField() && !displayItem.getFormItem().isEmpty()) { + return true; + } + } + return false; + } + + private void clearValidationErrors(List displayItems) { + for (OfflineFormDisplayItem displayItem : displayItems) { + if (displayItem.isField()) { + displayItem.getFormItem().clearError(); + } + } + } + + private OfflineFormRecord ensureDraftRecordForAttachment() { + if (_editingRecord == null) { + _editingRecord = OfflineFormRecord.createDraft( + _intent.getStringExtra("patternId"), + _intent.getStringExtra("schemaVersion"), + collectAllFormData()); + } else if (_editingRecord.getStatus() == OfflineFormRecordStatus.DRAFT) { + _editingRecord.setValues(collectAllFormData()); + _editingRecord.setUpdatedAt(System.currentTimeMillis()); + } + OfflineFormFileHelper.writeRecord(this, _editingRecord); + return _editingRecord; + } + + private boolean hasFieldBeforeNextStep() { + if (_definition == null || _definition.getSteps() == null) { + return false; + } + + int lastStepIndex = Math.min(_currentStepIndex, _definition.getSteps().size() - 1); + for (int i = 0; i <= lastStepIndex; i++) { + for (OfflineFormDisplayItem displayItem : OfflineFormDefinitionFlattener.flattenStep(_definition.getSteps().get(i))) { + if (displayItem.isField()) { + return true; + } + } + } + return false; + } + + private void loadEditingRecord(String patternId, String currentSchemaVersion, List formItems) { + String recordId = _intent.getStringExtra(EXTRA_RECORD_ID); + if (recordId == null || recordId.isEmpty()) { + return; + } + + OfflineFormRecord record = OfflineFormFileHelper.readRecord(this, patternId, recordId); + if (record == null || !currentSchemaVersion.equals(record.getSchemaVersion())) { + _editRecordMissingOrOutdated = true; + Toast.makeText(this, R.string.offline_toast_old_record_edit_not_supported, Toast.LENGTH_SHORT).show(); + return; + } + + _editingRecord = record; + applyRecordValues(formItems, record.getValues()); + } + + private void applyPatternIdToAttachmentItems(List formItems, String patternId) { + OfflineFormItemValueHelper.applyPatternId(formItems, patternId); + } + + private void applyRecordValues(List formItems, Map values) { + OfflineFormItemValueHelper.applyValues(formItems, values); + } + + @Override + public void captureImage(ImageFormItem item, AttachmentCallback callback) { + _pendingImageItem = item; + _pendingImageCallback = callback; + Intent cameraIntent = new Intent(this, CameraViewActivity.class); + cameraIntent.putExtra(CameraViewActivity.EXTRA_OPERATION, CameraViewActivity.OPERATION_TAKE_PHOTO); + cameraIntent.putStringArrayListExtra(CameraViewActivity.EXTRA_WATERMARK_LINES, OfflineImageFileHelper.buildWatermarkLines(item)); + cameraIntent.putExtra(CameraViewActivity.EXTRA_CONFIRM_PHOTO, true); + _imageCaptureLauncher.launch(cameraIntent); + } + + @Override + public void uploadImage(ImageFormItem item, AttachmentCallback callback) { + _pendingImageItem = item; + _pendingImageCallback = callback; + _imageUploadLauncher.launch("image/*"); + } + + @Override + public void previewImages(ImageFormItem item, ArrayList fileNames, int index) { + _pendingImageItem = item; + _imagePreviewLauncher.launch(OfflineImagePreviewActivity.createIntent(this, item.getPatternId(), fileNames, index)); + } + + @Override + public void onImagesChanged(ImageFormItem item) { + saveDraftIfNeeded(); + } + + @Override + public void uploadFile(FileFormItem item, AttachmentCallback callback) { + _pendingFileItem = item; + _pendingFileCallback = callback; + _fileUploadLauncher.launch(OfflineFileHelper.buildMimeTypes(item.getFileItemConfig())); + } + + @Override + public void onFilesChanged(FileFormItem item) { + saveDraftIfNeeded(); + } + + private int getRemainingImageCount(ImageFormItem item) { + if (item.getMaxCount() <= 0) { + return Integer.MAX_VALUE; + } + return Math.max(0, item.getMaxCount() - item.getImages().size()); + } + + private int getRemainingFileCount(FileFormItem item) { + int maxCount = item.getFileItemConfig() == null ? 0 : item.getFileItemConfig().getMaxCount(); + if (maxCount <= 0) { + return Integer.MAX_VALUE; + } + return Math.max(0, maxCount - item.getFiles().size()); + } + + private boolean containsOriginalName(List files, String originalName) { + if (files == null) { + return false; + } + for (AttachmentFormItemValue file : files) { + if (file != null && originalName.equals(file.getOriginalName())) { + return true; + } + } + return false; + } + + private void clearPendingImageCapture() { + _pendingImageItem = null; + _pendingImageCallback = null; + } + + private void clearPendingFileUpload() { + _pendingFileItem = null; + _pendingFileCallback = null; + } + + private void scrollToFirstError() { + List items = _adapter.getFormItems(); + for (int i = 0; i < items.size(); i++) { + if (items.get(i).getErrorMessage() != null) { + _recyclerView.scrollToPosition(i); + break; + } + } + } + +} diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/OfflineExportedRecordDetailActivity.java b/app/src/main/java/com/huozige/lab/container/offlineform/OfflineExportedRecordDetailActivity.java new file mode 100644 index 00000000..85213454 --- /dev/null +++ b/app/src/main/java/com/huozige/lab/container/offlineform/OfflineExportedRecordDetailActivity.java @@ -0,0 +1,36 @@ +package com.huozige.lab.container.offlineform; + +import android.os.Bundle; +import android.widget.TextView; +import android.widget.Toast; + +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.serializer.SerializerFeature; +import com.huozige.lab.container.BaseActivity; +import com.huozige.lab.container.R; +import com.huozige.lab.container.offlineform.model.OfflineFormRecord; +import com.huozige.lab.container.proxy.support.offlinecustomform.helper.OfflineFormFileHelper; + +public class OfflineExportedRecordDetailActivity extends BaseActivity { + public static final String EXTRA_PATTERN_ID = "patternId"; + public static final String EXTRA_RECORD_ID = "recordId"; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setTitle(R.string.offline_title_exported_record_detail); + setContentView(R.layout.offline_exported_record_detail_activity); + + String patternId = getIntent().getStringExtra(EXTRA_PATTERN_ID); + String recordId = getIntent().getStringExtra(EXTRA_RECORD_ID); + OfflineFormRecord record = OfflineFormFileHelper.readRecord(this, patternId, recordId); + if (record == null) { + Toast.makeText(this, R.string.offline_toast_record_missing, Toast.LENGTH_SHORT).show(); + finish(); + return; + } + + TextView recordJsonTextView = findViewById(R.id.recordJsonTextView); + recordJsonTextView.setText(JSON.toJSONString(record, SerializerFeature.PrettyFormat)); + } +} diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/OfflineFormConfigDetailActivity.java b/app/src/main/java/com/huozige/lab/container/offlineform/OfflineFormConfigDetailActivity.java new file mode 100644 index 00000000..9a04971e --- /dev/null +++ b/app/src/main/java/com/huozige/lab/container/offlineform/OfflineFormConfigDetailActivity.java @@ -0,0 +1,289 @@ +package com.huozige.lab.container.offlineform; + +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.view.View; +import android.widget.AdapterView; +import android.widget.ArrayAdapter; +import android.widget.CheckBox; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.Spinner; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.appcompat.app.AlertDialog; + +import com.huozige.lab.container.BaseActivity; +import com.huozige.lab.container.R; +import com.huozige.lab.container.offlineform.model.OfflineComputedInfo; +import com.huozige.lab.container.offlineform.model.OfflineFormDisplayItem; +import com.huozige.lab.container.offlineform.model.OfflineFormDefinitionFlattener; +import com.huozige.lab.container.offlineform.model.OfflineFormRecordStatus; +import com.huozige.lab.container.proxy.support.offlinecustomform.helper.OfflineFormFileHelper; +import com.huozige.lab.container.offlineform.model.OfflineFormDefinition; +import com.huozige.lab.container.offlineform.model.OfflineFormDefinitionFile; +import com.huozige.lab.container.offlineform.model.formitem.common.BaseFormItem; +import com.huozige.lab.container.proxy.support.pdf.PDFPreviewActivity; +import com.huozige.lab.container.utilities.DeviceUtility; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; + +public class OfflineFormConfigDetailActivity extends BaseActivity { + public static final String EXTRA_PATTERN_ID = "patternId"; + private static final Integer[] RECORD_PAGE_SIZE_OPTIONS = new Integer[]{1, 10, 20, 50, 100}; + + private String _patternId; + private OfflineFormDefinitionFile _definitionFile; + private boolean _recordPageSizeRendering; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setTitle(R.string.offline_menu_config_detail); + setContentView(R.layout.offline_form_config_detail_activity); + + _patternId = getIntent().getStringExtra(EXTRA_PATTERN_ID); + findViewById(R.id.cmdDeleteDraftRecords).setOnClickListener(v -> confirmDeleteRecordsByStatus( + OfflineFormRecordStatus.DRAFT, + R.string.offline_dialog_delete_draft_records_message)); + findViewById(R.id.cmdDeleteExportedRecords).setOnClickListener(v -> confirmDeleteRecordsByStatus( + OfflineFormRecordStatus.EXPORTED, + R.string.offline_dialog_delete_exported_records_message)); + findViewById(R.id.cmdOpenManual).setOnClickListener(v -> openManualPdf()); + findViewById(R.id.cmdDeleteConfig).setOnClickListener(v -> confirmDeleteConfig()); + renderConfig(); + } + + private void renderConfig() { + if (_patternId == null || _patternId.isEmpty()) { + Toast.makeText(this, R.string.offline_toast_config_missing, Toast.LENGTH_SHORT).show(); + finish(); + return; + } + + _definitionFile = OfflineFormFileHelper.readDefinition(this, _patternId); + if (_definitionFile == null) { + Toast.makeText(this, R.string.offline_toast_config_missing, Toast.LENGTH_SHORT).show(); + finish(); + return; + } + + OfflineFormDefinition definition = _definitionFile.getJsonSchema(); + String definitionFilePath = OfflineFormFileHelper.getDefinitionFilePath(this, _patternId); + + TextView basicInfoTextView = findViewById(R.id.basicInfoTextView); + basicInfoTextView.setText(getString( + R.string.offline_text_config_basic_info, + definition.getPatternId(), + definition.getSchemaVersion(), + definition.getTitle(), + definition.getDescription(), + OfflineFormDefinitionFlattener.flattenFields(definition).size(), + definitionFilePath)); + + renderDisplayColumns(definition); + renderResources(); + renderRecordPageSize(); + } + + private void renderResources() { + View resourceCard = findViewById(R.id.resourceCard); + View manualButton = findViewById(R.id.cmdOpenManual); + TextView signatureTitleTextView = findViewById(R.id.signatureTitleTextView); + ImageView signatureImageView = findViewById(R.id.signatureImageView); + + File manualFile = OfflineFormFileHelper.getManualPdfFile(this, _patternId); + boolean hasManual = manualFile.exists(); + manualButton.setVisibility(hasManual ? View.VISIBLE : View.GONE); + + File signatureFile = OfflineFormFileHelper.getSignatureFile(this, _patternId); + boolean hasSignature = signatureFile.exists(); + signatureTitleTextView.setVisibility(hasSignature ? View.VISIBLE : View.GONE); + signatureImageView.setVisibility(hasSignature ? View.VISIBLE : View.GONE); + if (hasSignature) { + signatureImageView.setImageURI(Uri.fromFile(signatureFile)); + } + + resourceCard.setVisibility(hasManual || hasSignature ? View.VISIBLE : View.GONE); + } + + private void openManualPdf() { + File manualFile = OfflineFormFileHelper.getManualPdfFile(this, _patternId); + if (!manualFile.exists()) { + Toast.makeText(this, R.string.offline_toast_manual_missing, Toast.LENGTH_SHORT).show(); + return; + } + + Intent intent = new Intent(this, PDFPreviewActivity.class); + intent.putExtra(PDFPreviewActivity.EXTRA_KEY_LOCAL_FILE_PATH, manualFile.getAbsolutePath()); + intent.putExtra(PDFPreviewActivity.EXTRA_KEY_FILENAME, manualFile.getName()); + try { + startActivity(intent); + } catch (RuntimeException e) { + openManualPdfWithExternalApp(manualFile); + } + } + + private void openManualPdfWithExternalApp(File manualFile) { + Intent intent = new Intent(Intent.ACTION_VIEW); + intent.setDataAndType(DeviceUtility.pathToUri(manualFile.getAbsolutePath()), "application/pdf"); + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + try { + startActivity(intent); + } catch (RuntimeException e) { + Toast.makeText(this, R.string.offline_toast_no_app_open_file, Toast.LENGTH_SHORT).show(); + } + } + + private void renderRecordPageSize() { + Spinner recordPageSizeSpinner = findViewById(R.id.recordPageSizeSpinner); + ArrayAdapter adapter = new ArrayAdapter<>(this, android.R.layout.simple_spinner_item, RECORD_PAGE_SIZE_OPTIONS); + adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + recordPageSizeSpinner.setAdapter(adapter); + + _recordPageSizeRendering = true; + int recordPageSize = getRecordPageSize(); + int selectedIndex = 0; + for (int i = 0; i < RECORD_PAGE_SIZE_OPTIONS.length; i++) { + if (RECORD_PAGE_SIZE_OPTIONS[i] == recordPageSize) { + selectedIndex = i; + break; + } + } + recordPageSizeSpinner.setSelection(selectedIndex); + _recordPageSizeRendering = false; + + recordPageSizeSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { + @Override + public void onItemSelected(AdapterView parent, View view, int position, long id) { + if (_recordPageSizeRendering) { + return; + } + saveRecordPageSize(RECORD_PAGE_SIZE_OPTIONS[position]); + } + + @Override + public void onNothingSelected(AdapterView parent) { + } + }); + } + + private void renderDisplayColumns(OfflineFormDefinition definition) { + LinearLayout displayColumnListLayout = findViewById(R.id.displayColumnListLayout); + displayColumnListLayout.removeAllViews(); + + List displayColumns = getDisplayColumns(); + for (OfflineFormDisplayItem displayItem : OfflineFormDefinitionFlattener.flattenFieldDisplayItems(definition)) { + BaseFormItem formItem = displayItem.getFormItem(); + CheckBox checkBox = new CheckBox(this); + String title = displayItem.getFieldDisplayTitle(); + checkBox.setText(title == null || title.isEmpty() ? formItem.getId() : title); + checkBox.setTextSize(14); + checkBox.setTag(formItem.getId()); + checkBox.setChecked(displayColumns.contains(formItem.getId())); + checkBox.setOnCheckedChangeListener((buttonView, isChecked) -> saveDisplayColumns()); + displayColumnListLayout.addView(checkBox); + } + } + + private void saveDisplayColumns() { + if (_definitionFile == null || _definitionFile.getComputed() == null) { + return; + } + + LinearLayout displayColumnListLayout = findViewById(R.id.displayColumnListLayout); + List selectedColumns = new ArrayList<>(); + int checkBoxCount = displayColumnListLayout.getChildCount(); + for (int i = 0; i < checkBoxCount; i++) { + CheckBox checkBox = (CheckBox) displayColumnListLayout.getChildAt(i); + if (checkBox.isChecked()) { + selectedColumns.add((String) checkBox.getTag()); + } + } + + _definitionFile.getComputed().setDisplayColumns(selectedColumns); + OfflineFormFileHelper.writeDefinition(this, _patternId, _definitionFile); + } + + private List getDisplayColumns() { + if (_definitionFile == null + || _definitionFile.getComputed() == null + || _definitionFile.getComputed().getDisplayColumns() == null) { + return new ArrayList<>(); + } + return _definitionFile.getComputed().getDisplayColumns(); + } + + private int getRecordPageSize() { + if (_definitionFile == null || _definitionFile.getComputed() == null) { + return OfflineComputedInfo.DEFAULT_RECORD_PAGE_SIZE; + } + return normalizeRecordPageSize(_definitionFile.getComputed().getRecordPageSize()); + } + + private int normalizeRecordPageSize(int pageSize) { + for (int option : RECORD_PAGE_SIZE_OPTIONS) { + if (option == pageSize) { + return pageSize; + } + } + return OfflineComputedInfo.DEFAULT_RECORD_PAGE_SIZE; + } + + private void saveRecordPageSize(int recordPageSize) { + if (_definitionFile == null || _definitionFile.getComputed() == null) { + return; + } + + _definitionFile.getComputed().setRecordPageSize(recordPageSize); + OfflineFormFileHelper.writeDefinition(this, _patternId, _definitionFile); + } + + private void confirmDeleteConfig() { + if (_patternId == null || _patternId.isEmpty()) { + Toast.makeText(this, R.string.offline_toast_config_missing, Toast.LENGTH_SHORT).show(); + return; + } + + new AlertDialog.Builder(this) + .setTitle(R.string.offline_dialog_delete_title) + .setMessage(R.string.offline_dialog_delete_config_message) + .setPositiveButton(R.string.offline_button_delete, (dialog, which) -> deleteConfig()) + .setNegativeButton(R.string.ui_button_cancel, null) + .show(); + } + + private void confirmDeleteRecordsByStatus(OfflineFormRecordStatus status, int messageResId) { + if (_patternId == null || _patternId.isEmpty()) { + Toast.makeText(this, R.string.offline_toast_config_missing, Toast.LENGTH_SHORT).show(); + return; + } + + new AlertDialog.Builder(this) + .setTitle(R.string.offline_dialog_delete_title) + .setMessage(messageResId) + .setPositiveButton(R.string.offline_button_delete, (dialog, which) -> deleteRecordsByStatus(status)) + .setNegativeButton(R.string.ui_button_cancel, null) + .show(); + } + + private void deleteRecordsByStatus(OfflineFormRecordStatus status) { + int deletedCount = OfflineFormFileHelper.deleteRecordsByStatus(this, _patternId, status); + Toast.makeText(this, getString(R.string.offline_toast_delete_records_success, deletedCount), Toast.LENGTH_SHORT).show(); + } + + private void deleteConfig() { + if (!OfflineFormFileHelper.deletePatternDirectory(this, _patternId)) { + Toast.makeText(this, R.string.offline_toast_delete_failed, Toast.LENGTH_SHORT).show(); + return; + } + + OfflineFormFileHelper.removeDefinitionOrder(this, _patternId); + Toast.makeText(this, R.string.offline_toast_delete_success, Toast.LENGTH_SHORT).show(); + finish(); + } +} diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/OfflineFormRawConfigActivity.java b/app/src/main/java/com/huozige/lab/container/offlineform/OfflineFormRawConfigActivity.java new file mode 100644 index 00000000..9a36803a --- /dev/null +++ b/app/src/main/java/com/huozige/lab/container/offlineform/OfflineFormRawConfigActivity.java @@ -0,0 +1,45 @@ +package com.huozige.lab.container.offlineform; + +import android.os.Bundle; +import android.widget.TextView; +import android.widget.Toast; + +import com.huozige.lab.container.BaseActivity; +import com.huozige.lab.container.R; +import com.huozige.lab.container.proxy.support.offlinecustomform.helper.OfflineFormFileHelper; + +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONObject; +import com.alibaba.fastjson.serializer.SerializerFeature; + +public class OfflineFormRawConfigActivity extends BaseActivity { + public static final String EXTRA_PATTERN_ID = "patternId"; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setTitle(R.string.offline_title_raw_config); + setContentView(R.layout.offline_form_raw_config_activity); + + String patternId = getIntent().getStringExtra(EXTRA_PATTERN_ID); + if (patternId == null || patternId.isEmpty()) { + Toast.makeText(this, R.string.offline_toast_config_missing, Toast.LENGTH_SHORT).show(); + finish(); + return; + } + + JSONObject rawDefinitionJson = OfflineFormFileHelper.readRawDefinitionJson(this, patternId); + if (rawDefinitionJson == null) { + Toast.makeText(this, R.string.offline_toast_config_missing, Toast.LENGTH_SHORT).show(); + finish(); + return; + } + + TextView rawConfigTextView = findViewById(R.id.rawConfigTextView); + rawConfigTextView.setText(formatJson(rawDefinitionJson)); + } + + private String formatJson(JSONObject jsonObject) { + return JSON.toJSONString(jsonObject, SerializerFeature.PrettyFormat); + } +} diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/OfflinePlusExportListActivity.java b/app/src/main/java/com/huozige/lab/container/offlineform/OfflinePlusExportListActivity.java new file mode 100644 index 00000000..ef30a1b8 --- /dev/null +++ b/app/src/main/java/com/huozige/lab/container/offlineform/OfflinePlusExportListActivity.java @@ -0,0 +1,168 @@ +package com.huozige.lab.container.offlineform; + +import android.content.Intent; +import android.os.Bundle; +import android.text.Editable; +import android.text.TextWatcher; +import android.widget.Button; +import android.widget.CheckBox; +import android.widget.EditText; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.huozige.lab.container.BaseActivity; +import com.huozige.lab.container.R; +import com.huozige.lab.container.offlineform.model.OfflineFormDefinitionIndexItem; +import com.huozige.lab.container.proxy.support.offlinecustomform.OfflinePlusExportCardAdapter; +import com.huozige.lab.container.proxy.support.offlinecustomform.helper.OfflineFormExportStatusHelper; +import com.huozige.lab.container.proxy.support.offlinecustomform.helper.OfflineFormFileHelper; + +import java.util.ArrayList; +import java.util.List; + +public class OfflinePlusExportListActivity extends BaseActivity { + public static final String EXTRA_SELECTED_PATTERN_IDS = "selectedPatternIds"; + + private OfflinePlusExportCardAdapter _adapter; + private List _cardItemList; + private List _displayedCardItemList; + private boolean _showUnexportedOnly = true; + private EditText _searchEditText; + private TextView _cmdFilterUnexported; + private TextView _cmdFilterAll; + private CheckBox _cmdSelectAll; + private Button _cmdExport; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setTitle("\u9009\u62e9\u5bfc\u51fa\u8868\u5355"); + setContentView(R.layout.offline_form_export_list_activity); + + _searchEditText = findViewById(R.id.searchEditText); + _cmdFilterUnexported = findViewById(R.id.cmdFilterUnexported); + _cmdFilterAll = findViewById(R.id.cmdFilterAll); + _cmdSelectAll = findViewById(R.id.cmdSelectAll); + _cmdExport = findViewById(R.id.cmdExport); + + RecyclerView recyclerView = findViewById(R.id.recyclerView); + recyclerView.setLayoutManager(new LinearLayoutManager(this)); + + _cardItemList = generateData(); + _displayedCardItemList = _cardItemList; + + _adapter = new OfflinePlusExportCardAdapter(_displayedCardItemList, this, this::updateActions); + recyclerView.setAdapter(_adapter); + + setupActions(); + applySearch(_searchEditText.getText().toString()); + } + + @Override + protected void onResume() { + super.onResume(); + reloadList(); + } + + private List generateData() { + return OfflineFormFileHelper.readDefinitions(this); + } + + private void reloadList() { + if (_adapter == null) { + return; + } + + _cardItemList = generateData(); + applySearch(_searchEditText.getText().toString()); + } + + private void setupActions() { + _cmdFilterUnexported.setOnClickListener(v -> setFilterMode(true)); + _cmdFilterAll.setOnClickListener(v -> setFilterMode(false)); + _cmdSelectAll.setOnClickListener(v -> toggleDisplayedSelection()); + _cmdExport.setOnClickListener(v -> finishWithSelectedItems()); + _searchEditText.addTextChangedListener(new TextWatcher() { + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + applySearch(s == null ? "" : s.toString()); + } + + @Override + public void afterTextChanged(Editable s) { + } + }); + } + + private void setFilterMode(boolean showUnexportedOnly) { + if (_showUnexportedOnly == showUnexportedOnly) { + return; + } + + _showUnexportedOnly = showUnexportedOnly; + applySearch(_searchEditText.getText().toString()); + updateActions(); + } + + private void toggleDisplayedSelection() { + boolean shouldSelect = !_adapter.areAllSelected(_displayedCardItemList); + _adapter.setSelected(_displayedCardItemList, shouldSelect); + updateActions(); + } + + private void finishWithSelectedItems() { + ArrayList selectedPatternIds = _adapter.getSelectedPatternIdsInOrder(_cardItemList); + if (selectedPatternIds.isEmpty()) { + Toast.makeText(this, "\u8bf7\u81f3\u5c11\u9009\u62e9\u4e00\u4e2a\u8868\u5355", Toast.LENGTH_SHORT).show(); + return; + } + + Intent result = new Intent(); + result.putStringArrayListExtra(EXTRA_SELECTED_PATTERN_IDS, selectedPatternIds); + setResult(RESULT_OK, result); + finish(); + } + + private void applySearch(String keyword) { + String normalizedKeyword = keyword == null ? "" : keyword.trim().toLowerCase(); + _displayedCardItemList = new ArrayList<>(); + for (OfflineFormDefinitionIndexItem item : _cardItemList) { + if (_showUnexportedOnly && !OfflineFormExportStatusHelper.hasUnexportedRecords(this, item)) { + continue; + } + if (normalizedKeyword.isEmpty() + || containsKeyword(item.getTitle(), normalizedKeyword) + || containsKeyword(item.getDescription(), normalizedKeyword)) { + _displayedCardItemList.add(item); + } + } + + _adapter.updateData(_displayedCardItemList); + updateActions(); + } + + private boolean containsKeyword(String value, String keyword) { + return value != null && value.toLowerCase().contains(keyword); + } + + private void updateActions() { + if (_adapter == null) { + return; + } + _cmdFilterUnexported.setSelected(_showUnexportedOnly); + _cmdFilterAll.setSelected(!_showUnexportedOnly); + boolean hasDisplayedItems = _displayedCardItemList != null && !_displayedCardItemList.isEmpty(); + _cmdSelectAll.setEnabled(hasDisplayedItems); + boolean allDisplayedSelected = _adapter.areAllSelected(_displayedCardItemList); + _cmdSelectAll.setChecked(allDisplayedSelected); + _cmdSelectAll.setText(allDisplayedSelected ? "\u53d6\u6d88\u5168\u9009" : "\u5168\u9009"); + _cmdExport.setEnabled(_adapter.getSelectedCount() > 0); + } +} diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/OfflinePlusListActivity.java b/app/src/main/java/com/huozige/lab/container/offlineform/OfflinePlusListActivity.java new file mode 100644 index 00000000..1601f0cd --- /dev/null +++ b/app/src/main/java/com/huozige/lab/container/offlineform/OfflinePlusListActivity.java @@ -0,0 +1,198 @@ +package com.huozige.lab.container.offlineform; + +import android.os.Bundle; +import android.text.Editable; +import android.text.TextWatcher; +import android.widget.Button; +import android.widget.EditText; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.recyclerview.widget.ItemTouchHelper; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.huozige.lab.container.BaseActivity; +import com.huozige.lab.container.R; +import com.huozige.lab.container.proxy.support.offlinecustomform.OfflinePlusCardAdapter; +import com.huozige.lab.container.proxy.support.offlinecustomform.helper.OfflineFormExportStatusHelper; +import com.huozige.lab.container.proxy.support.offlinecustomform.helper.OfflineFormFileHelper; +import com.huozige.lab.container.offlineform.model.OfflineFormDefinitionIndexItem; +import com.huozige.lab.container.utilities.StringUtils; + +import java.util.ArrayList; +import java.util.List; + +public class OfflinePlusListActivity extends BaseActivity { + private OfflinePlusCardAdapter _adapter; + private List _cardItemList; + private List _displayedCardItemList; + private boolean _sortMode; + private boolean _showUnexportedOnly = true; + private EditText _searchEditText; + private TextView _cmdFilterUnexported; + private TextView _cmdFilterAll; + private Button _cmdSort; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setTitle(R.string.offline_title_project_list); + setContentView(R.layout.offline_form_list_activity); + + _searchEditText = findViewById(R.id.searchEditText); + _cmdFilterUnexported = findViewById(R.id.cmdFilterUnexported); + _cmdFilterAll = findViewById(R.id.cmdFilterAll); + _cmdSort = findViewById(R.id.cmdSort); + + RecyclerView recyclerView = findViewById(R.id.recyclerView); + recyclerView.setLayoutManager(new LinearLayoutManager(this)); + + _cardItemList = generateData(); + _displayedCardItemList = _cardItemList; + + _adapter = new OfflinePlusCardAdapter(_displayedCardItemList, this, item -> reloadList()); + recyclerView.setAdapter(_adapter); + setupItemTouchHelper(recyclerView); + + setupActions(); + applySearch(_searchEditText.getText().toString()); + } + + @Override + protected void onResume() { + super.onResume(); + if (_sortMode) { + return; + } + reloadList(); + } + + private List generateData() { + + return OfflineFormFileHelper.readDefinitions(this); + + } + + private void reloadList() { + if (_adapter == null) { + return; + } + + _cardItemList = generateData(); + applySearch(_searchEditText.getText().toString()); + } + + private void setupActions() { + _cmdFilterUnexported.setOnClickListener(v -> setFilterMode(true)); + _cmdFilterAll.setOnClickListener(v -> setFilterMode(false)); + _cmdSort.setOnClickListener(v -> setSortMode(!_sortMode)); + _searchEditText.addTextChangedListener(new TextWatcher() { + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + applySearch(s == null ? "" : s.toString()); + } + + @Override + public void afterTextChanged(Editable s) { + } + }); + updateFilterActions(); + } + + private void setupItemTouchHelper(RecyclerView recyclerView) { + ItemTouchHelper itemTouchHelper = new ItemTouchHelper(new ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP | ItemTouchHelper.DOWN, 0) { + @Override + public boolean isLongPressDragEnabled() { + return _sortMode; + } + + @Override + public boolean onMove(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder, RecyclerView.ViewHolder target) { + if (!_sortMode) { + return false; + } + + _adapter.moveItem(viewHolder.getAdapterPosition(), target.getAdapterPosition()); + return true; + } + + @Override + public void onSwiped(RecyclerView.ViewHolder viewHolder, int direction) { + } + }); + itemTouchHelper.attachToRecyclerView(recyclerView); + } + + private void setSortMode(boolean sortMode) { + if (sortMode) { + if (_showUnexportedOnly) { + Toast.makeText(this, "\u8bf7\u5148\u5207\u6362\u5230\u5168\u90e8\u540e\u518d\u6392\u5e8f", Toast.LENGTH_SHORT).show(); + return; + } + if (StringUtils.isNotBlank(_searchEditText.getText().toString())) { + Toast.makeText(this, R.string.offline_toast_clear_search_before_sort, Toast.LENGTH_SHORT).show(); + return; + } + } + + _sortMode = sortMode; + _cmdSort.setText(sortMode ? R.string.offline_button_finish_sort : R.string.offline_button_sort); + _adapter.setSortMode(sortMode); + + if (sortMode) { + Toast.makeText(this, R.string.offline_toast_drag_to_sort, Toast.LENGTH_SHORT).show(); + } else { + saveList(); + Toast.makeText(this, R.string.offline_toast_sort_saved, Toast.LENGTH_SHORT).show(); + } + } + + private void setFilterMode(boolean showUnexportedOnly) { + if (_showUnexportedOnly == showUnexportedOnly) { + return; + } + + if (_sortMode) { + setSortMode(false); + } + _showUnexportedOnly = showUnexportedOnly; + applySearch(_searchEditText.getText().toString()); + updateFilterActions(); + } + + private void updateFilterActions() { + _cmdFilterUnexported.setSelected(_showUnexportedOnly); + _cmdFilterAll.setSelected(!_showUnexportedOnly); + } + + private void applySearch(String keyword) { + String normalizedKeyword = keyword == null ? "" : keyword.trim().toLowerCase(); + _displayedCardItemList = new ArrayList<>(); + for (OfflineFormDefinitionIndexItem item : _cardItemList) { + if (_showUnexportedOnly && !OfflineFormExportStatusHelper.hasUnexportedRecords(this, item)) { + continue; + } + if (normalizedKeyword.isEmpty() + || containsKeyword(item.getTitle(), normalizedKeyword) + || containsKeyword(item.getDescription(), normalizedKeyword)) { + _displayedCardItemList.add(item); + } + } + + _adapter.updateData(_displayedCardItemList); + } + + private boolean containsKeyword(String value, String keyword) { + return value != null && value.toLowerCase().contains(keyword); + } + + private void saveList() { + OfflineFormFileHelper.writeDefinitionOrder(this, _cardItemList); + } + +} diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/OfflineProjectRecordActivity.java b/app/src/main/java/com/huozige/lab/container/offlineform/OfflineProjectRecordActivity.java new file mode 100644 index 00000000..334ae1f9 --- /dev/null +++ b/app/src/main/java/com/huozige/lab/container/offlineform/OfflineProjectRecordActivity.java @@ -0,0 +1,365 @@ +package com.huozige.lab.container.offlineform; + +import android.content.Intent; +import android.graphics.Typeface; +import android.os.Bundle; +import android.text.TextUtils; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.view.Gravity; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.PopupMenu; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.appcompat.app.AlertDialog; +import androidx.cardview.widget.CardView; +import androidx.core.content.ContextCompat; + +import com.huozige.lab.container.BaseActivity; +import com.huozige.lab.container.R; +import com.huozige.lab.container.offlineform.model.OfflineFormDefinitionFile; +import com.huozige.lab.container.offlineform.model.OfflineFormRecord; +import com.huozige.lab.container.offlineform.model.OfflineFormRecordStatus; +import com.huozige.lab.container.proxy.support.offlinecustomform.helper.OfflineFormFileHelper; + +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Locale; + +import static com.huozige.lab.container.offlineform.util.OfflineFormUiUnitHelper.dp; + +public class OfflineProjectRecordActivity extends BaseActivity { + private static final int MENU_ID_CONFIG_MANAGE = 1; + private static final int MENU_ID_RAW_CONFIG = 2; + private static final int MENU_ID_DRAFT_CONTINUE = 1; + private static final int MENU_ID_DRAFT_DELETE = 2; + + private String _patternId; + private String _title; + private String _description; + private String _schemaVersion; + private LinearLayout _draftListLayout; + private LinearLayout _recordListLayout; + // 已导出记录表实例挂载的容器。 + private LinearLayout _exportedRecordListLayout; + private TextView _emptyRecordTextView; + // 已导出记录一级标题;没有已导出数据时隐藏。 + private TextView _exportedRecordTitleTextView; + private OfflineRecordTableView _submittedRecordTable; + private OfflineRecordTableView _exportedRecordTable; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.offline_form_project_record_activity); + + _patternId = getIntent().getStringExtra("patternId"); + _title = getIntent().getStringExtra("title"); + _description = getIntent().getStringExtra("description"); + _schemaVersion = getIntent().getStringExtra("schemaVersion"); + + setTitle(_title == null || _title.isEmpty() ? getString(R.string.offline_title_project_record) : _title); + TextView projectDescriptionTextView = findViewById(R.id.projectDescriptionTextView); + projectDescriptionTextView.setText(_description == null ? "" : _description); + + _draftListLayout = findViewById(R.id.draftListLayout); + _recordListLayout = findViewById(R.id.recordListLayout); + _exportedRecordListLayout = findViewById(R.id.exportedRecordListLayout); + _emptyRecordTextView = findViewById(R.id.emptyRecordTextView); + _exportedRecordTitleTextView = findViewById(R.id.exportedRecordTitleTextView); + _submittedRecordTable = new OfflineRecordTableView(this, _recordListLayout, true, createRecordTableCallback()); + _exportedRecordTable = new OfflineRecordTableView(this, _exportedRecordListLayout, false, createRecordTableCallback()); + + findViewById(R.id.cmdNewRecord).setOnClickListener(v -> openNewRecord()); + renderRecords(); + } + + @Override + protected void onResume() { + super.onResume(); + if (hasPatternId() && OfflineFormFileHelper.readDefinition(this, _patternId) == null) { + finish(); + return; + } + renderRecords(); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + menu.add(0, MENU_ID_CONFIG_MANAGE, MENU_ID_CONFIG_MANAGE, R.string.offline_menu_config_manage); + menu.add(0, MENU_ID_RAW_CONFIG, MENU_ID_RAW_CONFIG, R.string.offline_title_raw_config); + return true; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (item.getItemId() == MENU_ID_CONFIG_MANAGE) { + showConfigDetail(); + return true; + } + if (item.getItemId() == MENU_ID_RAW_CONFIG) { + showRawConfig(); + return true; + } + return super.onOptionsItemSelected(item); + } + + private void openNewRecord() { + if (!hasPatternId()) { + Toast.makeText(this, R.string.offline_toast_config_missing, Toast.LENGTH_SHORT).show(); + return; + } + + Intent intent = new Intent(this, CustomFormActivity.class); + intent.putExtra("patternId", _patternId); + intent.putExtra("title", _title); + intent.putExtra("description", _description); + intent.putExtra("schemaVersion", _schemaVersion); + startActivity(intent); + } + + private void renderRecords() { + if (_recordListLayout == null || _exportedRecordListLayout == null) { + return; + } + + _submittedRecordTable.clear(); + _exportedRecordTable.clear(); + _exportedRecordTitleTextView.setVisibility(View.GONE); + if (!hasPatternId()) { + _emptyRecordTextView.setVisibility(View.VISIBLE); + return; + } + + OfflineFormDefinitionFile definitionFile = OfflineFormFileHelper.readDefinition(this, _patternId); + if (definitionFile == null || definitionFile.getJsonSchema() == null) { + _emptyRecordTextView.setVisibility(View.VISIBLE); + return; + } + + List draftRecords = new ArrayList<>(); + List submittedRecords = new ArrayList<>(); + List exportedRecords = new ArrayList<>(); + for (OfflineFormRecord record : OfflineFormFileHelper.readRecords(this, _patternId)) { + if (record.getStatus() == OfflineFormRecordStatus.DRAFT) { + draftRecords.add(record); + } else if (record.getStatus() == OfflineFormRecordStatus.EXPORTED) { + exportedRecords.add(record); + } else if (record.getStatus() == OfflineFormRecordStatus.SUBMITTED) { + submittedRecords.add(record); + } + } + + renderDraftRecords(draftRecords, definitionFile.getJsonSchema().getSchemaVersion()); + _emptyRecordTextView.setVisibility(submittedRecords.isEmpty() && exportedRecords.isEmpty() ? View.VISIBLE : View.GONE); + if (!submittedRecords.isEmpty()) { + _submittedRecordTable.setRecords(definitionFile, submittedRecords); + } else { + _submittedRecordTable.clear(); + } + if (!exportedRecords.isEmpty()) { + _exportedRecordTitleTextView.setVisibility(View.VISIBLE); + _exportedRecordTable.setRecords(definitionFile, exportedRecords); + } else { + _exportedRecordTable.clear(); + } + } + + private void renderDraftRecords(List draftRecords, String currentSchemaVersion) { + _draftListLayout.removeAllViews(); + for (OfflineFormRecord draftRecord : draftRecords) { + _draftListLayout.addView(createDraftRecordCard(draftRecord, currentSchemaVersion)); + } + } + + private View createDraftRecordCard(OfflineFormRecord record, String currentSchemaVersion) { + CardView cardView = new CardView(this); + LinearLayout.LayoutParams cardParams = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + dp(this, 96)); + cardParams.setMargins(0, dp(this, 8), 0, 0); + cardView.setLayoutParams(cardParams); + cardView.setCardBackgroundColor(ContextCompat.getColor(this, android.R.color.white)); + cardView.setRadius(dp(this, 8)); + cardView.setCardElevation(dp(this, 3)); + cardView.setUseCompatPadding(true); + cardView.setContentPadding(dp(this, 18), dp(this, 18), dp(this, 10), dp(this, 18)); + cardView.setClickable(true); + cardView.setFocusable(true); + cardView.setForeground(ContextCompat.getDrawable(this, android.R.drawable.list_selector_background)); + cardView.setOnClickListener(v -> openRecord(record, currentSchemaVersion)); + + LinearLayout row = new LinearLayout(this); + row.setOrientation(LinearLayout.HORIZONTAL); + row.setGravity(Gravity.CENTER_VERTICAL); + + ImageView iconView = new ImageView(this); + iconView.setImageResource(R.drawable.ic_offline_draft_record); + iconView.setContentDescription(getString(R.string.offline_title_draft_record)); + row.addView(iconView, new LinearLayout.LayoutParams(dp(this, 40), dp(this, 40))); + + LinearLayout textLayout = new LinearLayout(this); + textLayout.setOrientation(LinearLayout.VERTICAL); + LinearLayout.LayoutParams textParams = new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1); + textParams.setMargins(dp(this, 14), 0, 0, 0); + row.addView(textLayout, textParams); + + TextView titleView = new TextView(this); + titleView.setText(R.string.offline_title_draft_record); + titleView.setTextColor(ContextCompat.getColor(this, android.R.color.black)); + titleView.setTextSize(18); + titleView.setTypeface(null, Typeface.BOLD); + textLayout.addView(titleView); + + TextView summaryView = new TextView(this); + summaryView.setText(getString(R.string.offline_summary_draft_record, formatRecordTime(record.getUpdatedAt()))); + summaryView.setTextColor(ContextCompat.getColor(this, android.R.color.darker_gray)); + summaryView.setTextSize(14); + summaryView.setSingleLine(true); + summaryView.setEllipsize(TextUtils.TruncateAt.END); + LinearLayout.LayoutParams summaryParams = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT); + summaryParams.setMargins(0, dp(this, 6), 0, 0); + textLayout.addView(summaryView, summaryParams); + + TextView moreButton = createActionButton(getString(R.string.offline_button_more), android.R.color.black); + moreButton.setOnClickListener(v -> { + v.setPressed(false); + showDraftActionMenu(moreButton, record, currentSchemaVersion); + }); + row.addView(moreButton, new LinearLayout.LayoutParams(dp(this, 40), LinearLayout.LayoutParams.MATCH_PARENT)); + + cardView.addView(row); + return cardView; + } + + private TextView createActionButton(String text, int colorResId) { + TextView button = new TextView(this); + button.setText(text); + button.setTextSize(13); + button.setTypeface(null, Typeface.BOLD); + button.setTextColor(ContextCompat.getColor(this, colorResId)); + button.setGravity(android.view.Gravity.CENTER); + button.setPadding(dp(this, 6), dp(this, 8), dp(this, 6), dp(this, 8)); + button.setMinWidth(dp(this, 36)); + button.setClickable(true); + button.setBackgroundResource(android.R.drawable.list_selector_background); + return button; + } + + private void showDraftActionMenu(View anchor, OfflineFormRecord record, String currentSchemaVersion) { + PopupMenu popupMenu = new PopupMenu(this, anchor); + popupMenu.getMenu().add(0, MENU_ID_DRAFT_CONTINUE, MENU_ID_DRAFT_CONTINUE, R.string.offline_button_continue_draft); + popupMenu.getMenu().add(0, MENU_ID_DRAFT_DELETE, MENU_ID_DRAFT_DELETE, R.string.offline_button_delete); + popupMenu.setOnMenuItemClickListener(item -> { + if (item.getItemId() == MENU_ID_DRAFT_CONTINUE) { + openRecord(record, currentSchemaVersion); + return true; + } + if (item.getItemId() == MENU_ID_DRAFT_DELETE) { + confirmDeleteRecord(record); + return true; + } + return false; + }); + popupMenu.show(); + } + + private void openRecord(OfflineFormRecord record, String currentSchemaVersion) { + if (!currentSchemaVersion.equals(record.getSchemaVersion())) { + Toast.makeText(this, R.string.offline_toast_old_record_edit_not_supported, Toast.LENGTH_SHORT).show(); + return; + } + + Intent intent = new Intent(this, CustomFormActivity.class); + intent.putExtra("patternId", _patternId); + intent.putExtra("title", _title); + intent.putExtra("description", _description); + intent.putExtra("schemaVersion", record.getSchemaVersion()); + intent.putExtra(CustomFormActivity.EXTRA_RECORD_ID, record.getRecordId()); + startActivity(intent); + } + + private void openExportedRecordDetail(OfflineFormRecord record) { + Intent intent = new Intent(this, OfflineExportedRecordDetailActivity.class); + intent.putExtra(OfflineExportedRecordDetailActivity.EXTRA_PATTERN_ID, _patternId); + intent.putExtra(OfflineExportedRecordDetailActivity.EXTRA_RECORD_ID, record.getRecordId()); + startActivity(intent); + } + + private void confirmDeleteRecord(OfflineFormRecord record) { + new AlertDialog.Builder(this) + .setTitle(R.string.offline_dialog_delete_title) + .setMessage(R.string.offline_dialog_delete_record_message) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(R.string.offline_button_delete, (dialog, which) -> deleteRecord(record)) + .show(); + } + + private void deleteRecord(OfflineFormRecord record) { + boolean success = OfflineFormFileHelper.deleteRecord(this, _patternId, record.getRecordId()); + Toast.makeText(this, success ? R.string.offline_toast_delete_success : R.string.offline_toast_delete_failed, Toast.LENGTH_SHORT).show(); + if (success) { + renderRecords(); + } + } + + private void showConfigDetail() { + if (!hasPatternId()) { + Toast.makeText(this, R.string.offline_toast_config_missing, Toast.LENGTH_SHORT).show(); + return; + } + + Intent intent = new Intent(this, OfflineFormConfigDetailActivity.class); + intent.putExtra(OfflineFormConfigDetailActivity.EXTRA_PATTERN_ID, _patternId); + startActivity(intent); + } + + private void showRawConfig() { + if (!hasPatternId()) { + Toast.makeText(this, R.string.offline_toast_config_missing, Toast.LENGTH_SHORT).show(); + return; + } + + Intent intent = new Intent(this, OfflineFormRawConfigActivity.class); + intent.putExtra(OfflineFormRawConfigActivity.EXTRA_PATTERN_ID, _patternId); + startActivity(intent); + } + + private boolean hasPatternId() { + return _patternId != null && !_patternId.isEmpty(); + } + + private String formatRecordTime(long time) { + if (time <= 0) { + return getString(R.string.offline_text_unknown_record_time); + } + return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.CHINA).format(new Date(time)); + } + + private OfflineRecordTableView.Callback createRecordTableCallback() { + return new OfflineRecordTableView.Callback() { + @Override + public void onEditRecord(OfflineFormRecord record, String currentSchemaVersion) { + openRecord(record, currentSchemaVersion); + } + + @Override + public void onViewRecord(OfflineFormRecord record) { + openExportedRecordDetail(record); + } + + @Override + public void onDeleteRecord(OfflineFormRecord record) { + confirmDeleteRecord(record); + } + }; + } + +} diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/OfflineRecordTableView.java b/app/src/main/java/com/huozige/lab/container/offlineform/OfflineRecordTableView.java new file mode 100644 index 00000000..d0b66147 --- /dev/null +++ b/app/src/main/java/com/huozige/lab/container/offlineform/OfflineRecordTableView.java @@ -0,0 +1,432 @@ +package com.huozige.lab.container.offlineform; + +import android.content.Context; +import android.graphics.Color; +import android.graphics.Typeface; +import android.text.TextUtils; +import android.view.Gravity; +import android.view.View; +import android.widget.HorizontalScrollView; +import android.widget.LinearLayout; +import android.widget.PopupMenu; +import android.widget.TableLayout; +import android.widget.TableRow; +import android.widget.TextView; + +import androidx.cardview.widget.CardView; +import androidx.core.content.ContextCompat; + +import com.huozige.lab.container.R; +import com.huozige.lab.container.offlineform.formitem.OfflineFormItemRegistry; +import com.huozige.lab.container.offlineform.model.OfflineComputedInfo; +import com.huozige.lab.container.offlineform.model.OfflineFormDefinitionFlattener; +import com.huozige.lab.container.offlineform.model.OfflineFormDefinitionFile; +import com.huozige.lab.container.offlineform.model.OfflineFormDisplayItem; +import com.huozige.lab.container.offlineform.model.OfflineFormRecord; +import com.huozige.lab.container.offlineform.model.formitem.common.BaseFormItem; + +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import static com.huozige.lab.container.offlineform.util.OfflineFormUiUnitHelper.dp; + +public class OfflineRecordTableView { + private static final int MENU_ID_RECORD_EDIT = 1; + private static final int MENU_ID_RECORD_DELETE = 2; + private static final int MENU_ID_RECORD_VIEW = 3; + private static final int[] RECORD_PAGE_SIZE_OPTIONS = new int[]{1, 10, 20, 50, 100}; + + private final Context _context; + private final LinearLayout _container; + private final boolean _editable; + private final Callback _callback; + + private OfflineFormDefinitionFile _definitionFile; + private List _records = new ArrayList<>(); + private int _pageIndex; + private LinearLayout _wrapper; + private TableLayout _dataTableLayout; + private TableLayout _actionTableLayout; + private LinearLayout _paginationLayout; + private TextView _previousButton; + private TextView _pageInfoTextView; + private TextView _nextButton; + + public OfflineRecordTableView(Context context, LinearLayout container, boolean editable, Callback callback) { + _context = context; + _container = container; + _editable = editable; + _callback = callback; + } + + public void setRecords(OfflineFormDefinitionFile definitionFile, List records) { + _definitionFile = definitionFile; + _records = records == null ? new ArrayList<>() : records; + render(); + } + + public void clear() { + _wrapper = null; + _dataTableLayout = null; + _actionTableLayout = null; + _paginationLayout = null; + _previousButton = null; + _pageInfoTextView = null; + _nextButton = null; + _container.removeAllViews(); + } + + private void render() { + if (_definitionFile == null || _records.isEmpty()) { + clear(); + return; + } + ensureTableView(); + refreshRows(); + refreshPagination(); + } + + private void ensureTableView() { + if (_wrapper != null) { + return; + } + + _wrapper = new LinearLayout(_context); + _wrapper.setOrientation(LinearLayout.VERTICAL); + _wrapper.setLayoutParams(new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT)); + + CardView cardView = new CardView(_context); + LinearLayout.LayoutParams cardParams = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT); + cardParams.setMargins(0, dp(_context, 4), 0, dp(_context, 8)); + cardView.setLayoutParams(cardParams); + cardView.setCardBackgroundColor(_editable ? ContextCompat.getColor(_context, android.R.color.white) : Color.rgb(245, 246, 248)); + cardView.setRadius(dp(_context, 8)); + cardView.setCardElevation(dp(_context, 2)); + cardView.setUseCompatPadding(true); + cardView.setContentPadding(dp(_context, 8), dp(_context, 8), dp(_context, 8), dp(_context, 8)); + + LinearLayout tableContainer = new LinearLayout(_context); + tableContainer.setOrientation(LinearLayout.HORIZONTAL); + tableContainer.setLayoutParams(new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT)); + + HorizontalScrollView scrollView = new HorizontalScrollView(_context); + scrollView.setHorizontalScrollBarEnabled(true); + scrollView.setLayoutParams(new LinearLayout.LayoutParams( + 0, + LinearLayout.LayoutParams.WRAP_CONTENT, + 1)); + + _dataTableLayout = new TableLayout(_context); + _dataTableLayout.setShrinkAllColumns(false); + _dataTableLayout.setStretchAllColumns(false); + + _actionTableLayout = new TableLayout(_context); + _actionTableLayout.setLayoutParams(new LinearLayout.LayoutParams( + dp(_context, 44), + LinearLayout.LayoutParams.WRAP_CONTENT)); + _actionTableLayout.setShrinkAllColumns(false); + _actionTableLayout.setStretchAllColumns(false); + + scrollView.addView(_dataTableLayout); + tableContainer.addView(scrollView); + tableContainer.addView(_actionTableLayout); + cardView.addView(tableContainer); + _wrapper.addView(cardView); + _wrapper.addView(createPaginationBar()); + _container.addView(_wrapper); + } + + private void refreshRows() { + int pageSize = getRecordPageSize(_definitionFile); + int pageCount = (_records.size() + pageSize - 1) / pageSize; + if (_pageIndex >= pageCount) { + _pageIndex = Math.max(0, pageCount - 1); + } + int fromIndex = _pageIndex * pageSize; + int toIndex = Math.min(fromIndex + pageSize, _records.size()); + List currentPageRecords = _records.subList(fromIndex, toIndex); + + List displayColumns = getDisplayColumns(_definitionFile); + Map columnTitles = buildColumnTitles(_definitionFile); + Map formItems = buildFormItems(_definitionFile); + String currentSchemaVersion = _definitionFile.getJsonSchema().getSchemaVersion(); + + _dataTableLayout.removeAllViews(); + _actionTableLayout.removeAllViews(); + _dataTableLayout.addView(createHeaderRow(displayColumns, columnTitles)); + _actionTableLayout.addView(createActionHeaderRow()); + for (OfflineFormRecord record : currentPageRecords) { + _dataTableLayout.addView(createRecordRow(record, displayColumns, formItems, currentSchemaVersion)); + _actionTableLayout.addView(createActionRow(record, currentSchemaVersion)); + } + } + + private void refreshPagination() { + int pageSize = getRecordPageSize(_definitionFile); + int pageCount = (_records.size() + pageSize - 1) / pageSize; + boolean showPagination = _records.size() > pageSize; + _paginationLayout.setVisibility(showPagination ? View.VISIBLE : View.GONE); + if (!showPagination) { + return; + } + _previousButton.setEnabled(_pageIndex > 0); + _previousButton.setClickable(_pageIndex > 0); + _previousButton.setTextColor(ContextCompat.getColor(_context, _pageIndex > 0 ? R.color.huozige_blue : android.R.color.darker_gray)); + _nextButton.setEnabled(_pageIndex < pageCount - 1); + _nextButton.setClickable(_pageIndex < pageCount - 1); + _nextButton.setTextColor(ContextCompat.getColor(_context, _pageIndex < pageCount - 1 ? R.color.huozige_blue : android.R.color.darker_gray)); + _pageInfoTextView.setText(_context.getString(R.string.offline_text_record_page_info, _pageIndex + 1, pageCount)); + } + + private View createPaginationBar() { + LinearLayout paginationLayout = new LinearLayout(_context); + _paginationLayout = paginationLayout; + paginationLayout.setOrientation(LinearLayout.HORIZONTAL); + paginationLayout.setGravity(Gravity.CENTER); + paginationLayout.setPadding(0, dp(_context, 8), 0, dp(_context, 4)); + + TextView previousButton = createPaginationButton(_context.getString(R.string.offline_button_previous_page)); + _previousButton = previousButton; + previousButton.setOnClickListener(v -> { + if (_pageIndex <= 0) { + return; + } + _pageIndex--; + render(); + }); + + TextView pageInfoTextView = new TextView(_context); + _pageInfoTextView = pageInfoTextView; + pageInfoTextView.setTextColor(ContextCompat.getColor(_context, android.R.color.darker_gray)); + pageInfoTextView.setTextSize(14); + pageInfoTextView.setGravity(Gravity.CENTER); + pageInfoTextView.setPadding(dp(_context, 14), 0, dp(_context, 14), 0); + + TextView nextButton = createPaginationButton(_context.getString(R.string.offline_button_next_page)); + _nextButton = nextButton; + nextButton.setOnClickListener(v -> { + int pageSize = getRecordPageSize(_definitionFile); + int pageCount = (_records.size() + pageSize - 1) / pageSize; + if (_pageIndex >= pageCount - 1) { + return; + } + _pageIndex++; + render(); + }); + + paginationLayout.addView(previousButton); + paginationLayout.addView(pageInfoTextView); + paginationLayout.addView(nextButton); + return paginationLayout; + } + + private TextView createPaginationButton(String text) { + TextView button = new TextView(_context); + button.setText(text); + button.setTextSize(14); + button.setGravity(Gravity.CENTER); + button.setMinWidth(dp(_context, 72)); + button.setPadding(dp(_context, 10), dp(_context, 8), dp(_context, 10), dp(_context, 8)); + button.setBackgroundResource(android.R.drawable.list_selector_background); + return button; + } + + private TableRow createHeaderRow(List displayColumns, Map columnTitles) { + TableRow row = new TableRow(_context); + for (String fieldId : displayColumns) { + row.addView(createCell(resolveColumnTitle(columnTitles, fieldId), true)); + } + row.addView(createCell(_context.getString(R.string.offline_table_column_version), true)); + row.addView(createCell(_context.getString(R.string.offline_table_column_updated_at), true)); + return row; + } + + private TableRow createActionHeaderRow() { + TableRow row = new TableRow(_context); + row.addView(createCell(_context.getString(R.string.offline_table_column_actions), true)); + return row; + } + + private TableRow createRecordRow(OfflineFormRecord record, List displayColumns, Map formItems, String currentSchemaVersion) { + TableRow row = new TableRow(_context); + row.setClickable(true); + row.setBackgroundResource(android.R.drawable.list_selector_background); + for (String fieldId : displayColumns) { + row.addView(createRecordValueCell(record, fieldId, formItems.get(fieldId))); + } + row.addView(createCell(record.getSchemaVersion(), false)); + row.addView(createCell(formatRecordTime(record.getUpdatedAt()), false)); + row.setOnClickListener(v -> { + if (_editable) { + _callback.onEditRecord(record, currentSchemaVersion); + return; + } + _callback.onViewRecord(record); + }); + return row; + } + + private TableRow createActionRow(OfflineFormRecord record, String currentSchemaVersion) { + TableRow row = new TableRow(_context); + row.addView(createActionCell(record, currentSchemaVersion)); + return row; + } + + private TextView createCell(String text, boolean header) { + TextView textView = new TextView(_context); + textView.setMinWidth(dp(_context, 120)); + textView.setMaxWidth(dp(_context, 220)); + textView.setPadding(dp(_context, 12), dp(_context, 10), dp(_context, 12), dp(_context, 10)); + textView.setSingleLine(true); + textView.setEllipsize(TextUtils.TruncateAt.END); + textView.setText(text == null ? "" : text); + textView.setTextSize(header ? 14 : 13); + textView.setTextColor(ContextCompat.getColor(_context, header ? android.R.color.black : android.R.color.darker_gray)); + if (header) { + textView.setTypeface(null, Typeface.BOLD); + } + return textView; + } + + private View createRecordValueCell(OfflineFormRecord record, String fieldId, BaseFormItem formItem) { + String rawValue = getRecordValue(record, fieldId); + if (formItem == null) { + return createCell(rawValue, false); + } + return OfflineFormItemRegistry.createReadOnlyView(_context, formItem, rawValue, true); + } + + private View createActionCell(OfflineFormRecord record, String currentSchemaVersion) { + LinearLayout actions = new LinearLayout(_context); + actions.setOrientation(LinearLayout.HORIZONTAL); + actions.setPadding(dp(_context, 4), dp(_context, 2), dp(_context, 4), dp(_context, 2)); + actions.setMinimumWidth(dp(_context, 24)); + + TextView moreButton = createActionButton(_context.getString(R.string.offline_button_more), android.R.color.black); + moreButton.setOnClickListener(v -> showRecordActionMenu(moreButton, record, currentSchemaVersion)); + actions.addView(moreButton); + + return actions; + } + + private TextView createActionButton(String text, int colorResId) { + TextView button = new TextView(_context); + button.setText(text); + button.setTextSize(13); + button.setTypeface(null, Typeface.BOLD); + button.setTextColor(ContextCompat.getColor(_context, colorResId)); + button.setGravity(Gravity.CENTER); + button.setPadding(dp(_context, 6), dp(_context, 8), dp(_context, 6), dp(_context, 8)); + button.setMinWidth(dp(_context, 36)); + button.setClickable(true); + button.setBackgroundResource(android.R.drawable.list_selector_background); + return button; + } + + private void showRecordActionMenu(View anchor, OfflineFormRecord record, String currentSchemaVersion) { + PopupMenu popupMenu = new PopupMenu(_context, anchor); + if (_editable) { + popupMenu.getMenu().add(0, MENU_ID_RECORD_EDIT, MENU_ID_RECORD_EDIT, R.string.offline_button_edit); + } else { + popupMenu.getMenu().add(0, MENU_ID_RECORD_VIEW, MENU_ID_RECORD_VIEW, R.string.offline_button_view_detail); + } + popupMenu.getMenu().add(0, MENU_ID_RECORD_DELETE, MENU_ID_RECORD_DELETE, R.string.offline_button_delete); + popupMenu.setOnMenuItemClickListener(item -> { + if (item.getItemId() == MENU_ID_RECORD_EDIT) { + _callback.onEditRecord(record, currentSchemaVersion); + return true; + } + if (item.getItemId() == MENU_ID_RECORD_VIEW) { + _callback.onViewRecord(record); + return true; + } + if (item.getItemId() == MENU_ID_RECORD_DELETE) { + _callback.onDeleteRecord(record); + return true; + } + return false; + }); + popupMenu.show(); + } + + private List getDisplayColumns(OfflineFormDefinitionFile definitionFile) { + if (definitionFile.getComputed() != null && definitionFile.getComputed().getDisplayColumns() != null) { + return definitionFile.getComputed().getDisplayColumns(); + } + return new ArrayList<>(); + } + + private int getRecordPageSize(OfflineFormDefinitionFile definitionFile) { + if (definitionFile.getComputed() == null || definitionFile.getComputed().getRecordPageSize() <= 0) { + return OfflineComputedInfo.DEFAULT_RECORD_PAGE_SIZE; + } + int pageSize = definitionFile.getComputed().getRecordPageSize(); + for (int option : RECORD_PAGE_SIZE_OPTIONS) { + if (option == pageSize) { + return pageSize; + } + } + return OfflineComputedInfo.DEFAULT_RECORD_PAGE_SIZE; + } + + private Map buildColumnTitles(OfflineFormDefinitionFile definitionFile) { + Map titles = new HashMap<>(); + if (definitionFile.getJsonSchema() == null) { + return titles; + } + for (OfflineFormDisplayItem displayItem : OfflineFormDefinitionFlattener.flattenFieldDisplayItems(definitionFile.getJsonSchema())) { + titles.put(displayItem.getFormItem().getId(), displayItem.getFieldDisplayTitle()); + } + return titles; + } + + private Map buildFormItems(OfflineFormDefinitionFile definitionFile) { + Map formItems = new HashMap<>(); + if (definitionFile.getJsonSchema() == null) { + return formItems; + } + for (BaseFormItem formItem : OfflineFormDefinitionFlattener.flattenFields(definitionFile.getJsonSchema())) { + formItems.put(formItem.getId(), formItem); + } + return formItems; + } + + private String resolveColumnTitle(Map columnTitles, String fieldId) { + String title = columnTitles.get(fieldId); + return title == null || title.isEmpty() ? fieldId : title; + } + + private String getRecordValue(OfflineFormRecord record, String fieldId) { + if (record.getValues() == null) { + return ""; + } + String value = record.getValues().get(fieldId); + return value == null ? "" : value; + } + + private String formatRecordTime(long time) { + if (time <= 0) { + return _context.getString(R.string.offline_text_unknown_record_time); + } + return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.CHINA).format(new Date(time)); + } + + public interface Callback { + void onEditRecord(OfflineFormRecord record, String currentSchemaVersion); + + void onViewRecord(OfflineFormRecord record); + + void onDeleteRecord(OfflineFormRecord record); + } +} diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/ReselectSpinner.java b/app/src/main/java/com/huozige/lab/container/offlineform/ReselectSpinner.java new file mode 100644 index 00000000..787be451 --- /dev/null +++ b/app/src/main/java/com/huozige/lab/container/offlineform/ReselectSpinner.java @@ -0,0 +1,51 @@ +package com.huozige.lab.container.offlineform; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.View; +import android.widget.AdapterView; + +import androidx.appcompat.widget.AppCompatSpinner; + +public class ReselectSpinner extends AppCompatSpinner { + private AdapterView.OnItemSelectedListener _listener; + + public ReselectSpinner(Context context) { + super(context); + } + + public ReselectSpinner(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public ReselectSpinner(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + @Override + public void setOnItemSelectedListener(AdapterView.OnItemSelectedListener listener) { + super.setOnItemSelectedListener(listener); + _listener = listener; + } + + @Override + public void setSelection(int position) { + boolean reselected = position == getSelectedItemPosition(); + super.setSelection(position); + notifyReselected(position, reselected); + } + + @Override + public void setSelection(int position, boolean animate) { + boolean reselected = position == getSelectedItemPosition(); + super.setSelection(position, animate); + notifyReselected(position, reselected); + } + + private void notifyReselected(int position, boolean reselected) { + if (reselected && _listener != null) { + View view = getSelectedView(); + _listener.onItemSelected(this, view, position, getSelectedItemId()); + } + } +} diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/formitem/AttachmentCallback.java b/app/src/main/java/com/huozige/lab/container/offlineform/formitem/AttachmentCallback.java new file mode 100644 index 00000000..7fcefe9c --- /dev/null +++ b/app/src/main/java/com/huozige/lab/container/offlineform/formitem/AttachmentCallback.java @@ -0,0 +1,9 @@ +package com.huozige.lab.container.offlineform.formitem; + +import com.huozige.lab.container.offlineform.model.formitem.common.AttachmentFormItemValue; + +import java.util.List; + +public interface AttachmentCallback { + void onAttachmentsSelected(List attachments); +} diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/formitem/OfflineFormItemHandler.java b/app/src/main/java/com/huozige/lab/container/offlineform/formitem/OfflineFormItemHandler.java new file mode 100644 index 00000000..87aa17c4 --- /dev/null +++ b/app/src/main/java/com/huozige/lab/container/offlineform/formitem/OfflineFormItemHandler.java @@ -0,0 +1,47 @@ +package com.huozige.lab.container.offlineform.formitem; + +import android.content.Context; +import android.view.View; +import android.view.ViewGroup; + +import com.alibaba.fastjson.JSONObject; +import com.huozige.lab.container.offlineform.model.formitem.common.BaseFormItem; +import com.huozige.lab.container.offlineform.model.formitem.common.FormItemInput; +import com.huozige.lab.container.proxy.support.offlinecustomform.viewholder.BaseViewHolder; + +public interface OfflineFormItemHandler { + /** + * 返回插件 JSON 中用于区分表单项类型的 itemType。 + */ + String getType(); + + /** + * 返回 RecyclerView 用于区分可编辑 ViewHolder 类型的固定值。 + */ + int getViewType(); + + /** + * 将插件命令传入的表单项 DTO 转成 APP 内部表单项模型。 + */ + BaseFormItem fromInput(FormItemInput input); + + /** + * 返回本地 definition.json 中该类型专属 options 对应的数据模型。 + */ + Class getOptionsClass(); + + /** + * 将 APP 内部表单项模型转换成本地 definition.json 中保存的 JSON。 + */ + JSONObject toJson(BaseFormItem item); + + /** + * 创建填报页面中可编辑状态的 ViewHolder。 + */ + BaseViewHolder createEditViewHolder(ViewGroup parent); + + /** + * 创建历史记录表或详情页中只读状态的表单项 View。 + */ + View createReadOnlyView(Context context, BaseFormItem item, String rawValue, boolean compact); +} diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/formitem/OfflineFormItemJsonHelper.java b/app/src/main/java/com/huozige/lab/container/offlineform/formitem/OfflineFormItemJsonHelper.java new file mode 100644 index 00000000..c2786141 --- /dev/null +++ b/app/src/main/java/com/huozige/lab/container/offlineform/formitem/OfflineFormItemJsonHelper.java @@ -0,0 +1,34 @@ +package com.huozige.lab.container.offlineform.formitem; + +import com.alibaba.fastjson.JSONObject; +import com.huozige.lab.container.offlineform.model.formitem.common.BaseFormItem; +import com.huozige.lab.container.offlineform.model.formitem.common.FormItemInput; + +public final class OfflineFormItemJsonHelper { + private OfflineFormItemJsonHelper() { + } + + public static JSONObject buildBaseOutput(BaseFormItem item) { + JSONObject jsonObject = new JSONObject(); + jsonObject.put(OfflineFormItemJsonKeys.FIELD_ID, item.getId()); + jsonObject.put(OfflineFormItemJsonKeys.FIELD_TITLE, item.getTitle()); + jsonObject.put(OfflineFormItemJsonKeys.FIELD_HINT, item.getHint()); + jsonObject.put(OfflineFormItemJsonKeys.FIELD_REQUIRED, item.isRequired()); + jsonObject.put(OfflineFormItemJsonKeys.FIELD_ITEM_TYPE, item.getItemType()); + if (item.getValue() != null) { + jsonObject.put(OfflineFormItemJsonKeys.FIELD_VALUE, item.getValue()); + } + return jsonObject; + } + + public static FormItemInput buildBaseInput(JSONObject jsonObject) { + FormItemInput input = new FormItemInput(); + input.itemId = jsonObject.getString(OfflineFormItemJsonKeys.FIELD_ID); + input.title = jsonObject.getString(OfflineFormItemJsonKeys.FIELD_TITLE); + input.hint = jsonObject.getString(OfflineFormItemJsonKeys.FIELD_HINT); + input.required = jsonObject.getBooleanValue(OfflineFormItemJsonKeys.FIELD_REQUIRED); + input.itemType = jsonObject.getString(OfflineFormItemJsonKeys.FIELD_ITEM_TYPE); + input.value = jsonObject.getString(OfflineFormItemJsonKeys.FIELD_VALUE); + return input; + } +} diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/formitem/OfflineFormItemJsonKeys.java b/app/src/main/java/com/huozige/lab/container/offlineform/formitem/OfflineFormItemJsonKeys.java new file mode 100644 index 00000000..ec937d5d --- /dev/null +++ b/app/src/main/java/com/huozige/lab/container/offlineform/formitem/OfflineFormItemJsonKeys.java @@ -0,0 +1,14 @@ +package com.huozige.lab.container.offlineform.formitem; + +public final class OfflineFormItemJsonKeys { + public static final String FIELD_ID = "id"; + public static final String FIELD_TITLE = "title"; + public static final String FIELD_HINT = "hint"; + public static final String FIELD_REQUIRED = "required"; + public static final String FIELD_ITEM_TYPE = "itemType"; + public static final String FIELD_VALUE = "value"; + public static final String FIELD_OPTIONS = "options"; + + private OfflineFormItemJsonKeys() { + } +} diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/formitem/OfflineFormItemRegistry.java b/app/src/main/java/com/huozige/lab/container/offlineform/formitem/OfflineFormItemRegistry.java new file mode 100644 index 00000000..01d253c0 --- /dev/null +++ b/app/src/main/java/com/huozige/lab/container/offlineform/formitem/OfflineFormItemRegistry.java @@ -0,0 +1,91 @@ +package com.huozige.lab.container.offlineform.formitem; + +import android.content.Context; +import android.view.View; + +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONObject; +import com.huozige.lab.container.offlineform.formitem.file.FileFormItemHandler; +import com.huozige.lab.container.offlineform.formitem.image.ImageFormItemHandler; +import com.huozige.lab.container.offlineform.formitem.list.ListFormItemHandler; +import com.huozige.lab.container.offlineform.formitem.password.PasswordFormItemHandler; +import com.huozige.lab.container.offlineform.formitem.picker.DatePickerFormItemHandler; +import com.huozige.lab.container.offlineform.formitem.picker.TimePickerFormItemHandler; +import com.huozige.lab.container.offlineform.formitem.select.SelectFormItemHandler; +import com.huozige.lab.container.offlineform.formitem.text.TextFormItemHandler; +import com.huozige.lab.container.offlineform.model.formitem.common.BaseFormItem; +import com.huozige.lab.container.offlineform.model.formitem.common.FormItemInput; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +public final class OfflineFormItemRegistry { + private static final Map HANDLERS_BY_TYPE = new HashMap<>(); + + static { + register(new SelectFormItemHandler()); + register(new PasswordFormItemHandler()); + register(new DatePickerFormItemHandler()); + register(new TimePickerFormItemHandler()); + register(new ImageFormItemHandler()); + register(new FileFormItemHandler()); + register(new ListFormItemHandler()); + register(new TextFormItemHandler()); + } + + private OfflineFormItemRegistry() { + } + + public static void register(OfflineFormItemHandler handler) { + HANDLERS_BY_TYPE.put(handler.getType(), handler); + } + + public static Map getHandlers() { + return Collections.unmodifiableMap(HANDLERS_BY_TYPE); + } + + public static OfflineFormItemHandler getHandler(String type) { + OfflineFormItemHandler handler = HANDLERS_BY_TYPE.get(type); + if (handler != null) { + return handler; + } + throw new IllegalArgumentException("Unknown item type: " + type); + } + + public static int getViewType(BaseFormItem item) { + return getHandler(item.getItemType()).getViewType(); + } + + public static BaseFormItem fromInput(FormItemInput input) { + OfflineFormItemHandler handler = HANDLERS_BY_TYPE.get(input.itemType); + if (handler == null) { + handler = getHandler(OfflineFormItemType.TEXT.getValue()); + } + normalizeOptions(input, handler); + return handler.fromInput(input); + } + + private static void normalizeOptions(FormItemInput input, OfflineFormItemHandler handler) { + if (input.options == null) { + return; + } + Class optionsClass = handler.getOptionsClass(); + if (optionsClass == null || optionsClass.isInstance(input.options)) { + return; + } + input.options = JSON.parseObject(JSON.toJSONString(input.options), optionsClass); + } + + public static Class getOptionsClass(String type) { + return getHandler(type).getOptionsClass(); + } + + public static JSONObject toJson(BaseFormItem item) { + return getHandler(item.getItemType()).toJson(item); + } + + public static View createReadOnlyView(Context context, BaseFormItem item, String rawValue, boolean compact) { + return getHandler(item.getItemType()).createReadOnlyView(context, item, rawValue, compact); + } +} diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/formitem/OfflineFormItemType.java b/app/src/main/java/com/huozige/lab/container/offlineform/formitem/OfflineFormItemType.java new file mode 100644 index 00000000..19233c21 --- /dev/null +++ b/app/src/main/java/com/huozige/lab/container/offlineform/formitem/OfflineFormItemType.java @@ -0,0 +1,22 @@ +package com.huozige.lab.container.offlineform.formitem; + +public enum OfflineFormItemType { + TEXT("textItem"), + PASSWORD("passwordItem"), + SELECT("selectItem"), + DATE_PICKER("datePicker"), + TIME_PICKER("timePicker"), + IMAGE("imageItem"), + FILE("fileItem"), + LIST("listItem"); + + private final String value; + + OfflineFormItemType(String value) { + this.value = value; + } + + public String getValue() { + return value; + } +} diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/formitem/OfflineFormItemValueHelper.java b/app/src/main/java/com/huozige/lab/container/offlineform/formitem/OfflineFormItemValueHelper.java new file mode 100644 index 00000000..4fabf6e6 --- /dev/null +++ b/app/src/main/java/com/huozige/lab/container/offlineform/formitem/OfflineFormItemValueHelper.java @@ -0,0 +1,66 @@ +package com.huozige.lab.container.offlineform.formitem; + +import com.huozige.lab.container.offlineform.model.formitem.common.BaseFormItem; +import com.huozige.lab.container.offlineform.model.formitem.file.FileFormItem; +import com.huozige.lab.container.offlineform.model.formitem.image.ImageFormItem; +import com.huozige.lab.container.offlineform.model.formitem.list.ListFormItem; +import com.huozige.lab.container.offlineform.model.formitem.picker.PickerFormItem; +import com.huozige.lab.container.offlineform.model.formitem.select.SelectFormItem; +import com.huozige.lab.container.offlineform.model.formitem.text.TextFormItem; + +import java.util.List; +import java.util.Map; + +public final class OfflineFormItemValueHelper { + private OfflineFormItemValueHelper() { + } + + public static void applyValue(BaseFormItem formItem, String value) { + if (formItem == null || value == null) { + return; + } + if (formItem instanceof TextFormItem) { + ((TextFormItem) formItem).setValue(value); + } else if (formItem instanceof SelectFormItem) { + ((SelectFormItem) formItem).setSelectedValue(value); + } else if (formItem instanceof PickerFormItem) { + ((PickerFormItem) formItem).setValue(value); + } else if (formItem instanceof ImageFormItem) { + ((ImageFormItem) formItem).setValue(value); + } else if (formItem instanceof FileFormItem) { + ((FileFormItem) formItem).setValue(value); + } else if (formItem instanceof ListFormItem) { + ((ListFormItem) formItem).setValue(value); + } + } + + public static void applyValues(List formItems, Map values) { + if (formItems == null || values == null) { + return; + } + for (BaseFormItem formItem : formItems) { + if (formItem != null) { + applyValue(formItem, values.get(formItem.getId())); + } + } + } + + public static void applyPatternId(BaseFormItem formItem, String patternId) { + if (formItem instanceof ImageFormItem) { + ((ImageFormItem) formItem).setPatternId(patternId); + } else if (formItem instanceof FileFormItem) { + ((FileFormItem) formItem).setPatternId(patternId); + } else if (formItem instanceof ListFormItem) { + ((ListFormItem) formItem).setPatternId(patternId); + } + } + + public static void applyPatternId(List formItems, String patternId) { + if (formItems == null) { + return; + } + for (BaseFormItem formItem : formItems) { + applyPatternId(formItem, patternId); + } + } +} diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/formitem/OfflineFormItemViewType.java b/app/src/main/java/com/huozige/lab/container/offlineform/formitem/OfflineFormItemViewType.java new file mode 100644 index 00000000..d1217fbe --- /dev/null +++ b/app/src/main/java/com/huozige/lab/container/offlineform/formitem/OfflineFormItemViewType.java @@ -0,0 +1,22 @@ +package com.huozige.lab.container.offlineform.formitem; + +public enum OfflineFormItemViewType { + TEXT(1), + PASSWORD(2), + SELECT(3), + DATE_PICKER(4), + TIME_PICKER(5), + IMAGE(6), + FILE(7), + LIST(8); + + private final int value; + + OfflineFormItemViewType(int value) { + this.value = value; + } + + public int getValue() { + return value; + } +} diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/formitem/ReadOnlyFormItemViews.java b/app/src/main/java/com/huozige/lab/container/offlineform/formitem/ReadOnlyFormItemViews.java new file mode 100644 index 00000000..aa2df229 --- /dev/null +++ b/app/src/main/java/com/huozige/lab/container/offlineform/formitem/ReadOnlyFormItemViews.java @@ -0,0 +1,31 @@ +package com.huozige.lab.container.offlineform.formitem; + +import android.content.Context; +import android.text.TextUtils; +import android.widget.TextView; + +import androidx.core.content.ContextCompat; + +import static com.huozige.lab.container.offlineform.util.OfflineFormUiUnitHelper.dp; + +public final class ReadOnlyFormItemViews { + private ReadOnlyFormItemViews() { + } + + public static TextView createCompactValueView(Context context, String text) { + TextView textView = new TextView(context); + int densityPaddingHorizontal = dp(context, 12); + int densityPaddingVertical = dp(context, 10); + textView.setMinWidth(dp(context, 120)); + textView.setMaxWidth(dp(context, 220)); + textView.setPadding(densityPaddingHorizontal, densityPaddingVertical, densityPaddingHorizontal, densityPaddingVertical); + textView.setSingleLine(true); + textView.setEllipsize(TextUtils.TruncateAt.END); + textView.setText(text == null ? "" : text); + textView.setTextSize(13); + textView.setTextColor(ContextCompat.getColor(context, android.R.color.darker_gray)); + textView.setEnabled(false); + return textView; + } + +} diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/formitem/file/FileExtensionIconView.java b/app/src/main/java/com/huozige/lab/container/offlineform/formitem/file/FileExtensionIconView.java new file mode 100644 index 00000000..69d274ac --- /dev/null +++ b/app/src/main/java/com/huozige/lab/container/offlineform/formitem/file/FileExtensionIconView.java @@ -0,0 +1,90 @@ +package com.huozige.lab.container.offlineform.formitem.file; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Path; +import android.graphics.Typeface; +import android.util.AttributeSet; +import android.view.View; + +import androidx.annotation.Nullable; + +import com.huozige.lab.container.R; + +/** + * 附件上传后显示在附件列表里的icon。 + * 绘制一个带有文件后缀名的文件图标。 + */ +public class FileExtensionIconView extends View { + private final Paint filePaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint foldPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint textPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private String extension = ""; + + public FileExtensionIconView(Context context) { + super(context); + init(); + } + + public FileExtensionIconView(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + init(); + } + + private void init() { + filePaint.setColor(getContext().getColor(R.color.huozige_blue)); + filePaint.setStyle(Paint.Style.FILL); + foldPaint.setColor(getContext().getColor(R.color.white)); + foldPaint.setAlpha(190); + foldPaint.setStyle(Paint.Style.FILL); + textPaint.setColor(getContext().getColor(R.color.white)); + textPaint.setTextAlign(Paint.Align.CENTER); + textPaint.setTypeface(Typeface.DEFAULT_BOLD); + } + + public void setExtension(String extension) { + String value = extension == null ? "" : extension.trim().toUpperCase(); + if (value.startsWith(".")) { + value = value.substring(1); + } + this.extension = value.length() > 4 ? value.substring(0, 4) : value; + invalidate(); + } + + @Override + protected void onDraw(Canvas canvas) { + super.onDraw(canvas); + float width = getWidth(); + float height = getHeight(); + float foldSize = Math.min(width, height) * 0.28f; + float radius = Math.min(width, height) * 0.08f; + + Path filePath = new Path(); + filePath.moveTo(radius, 0); + filePath.lineTo(width - foldSize, 0); + filePath.lineTo(width, foldSize); + filePath.lineTo(width, height - radius); + filePath.quadTo(width, height, width - radius, height); + filePath.lineTo(radius, height); + filePath.quadTo(0, height, 0, height - radius); + filePath.lineTo(0, radius); + filePath.quadTo(0, 0, radius, 0); + filePath.close(); + canvas.drawPath(filePath, filePaint); + + Path foldPath = new Path(); + foldPath.moveTo(width - foldSize, 0); + foldPath.lineTo(width, foldSize); + foldPath.lineTo(width - foldSize, foldSize); + foldPath.close(); + canvas.drawPath(foldPath, foldPaint); + + if (!extension.isEmpty()) { + textPaint.setTextSize(Math.max(9f, width / 4.6f)); + Paint.FontMetrics metrics = textPaint.getFontMetrics(); + float baseline = height * 0.65f - (metrics.ascent + metrics.descent) / 2f; + canvas.drawText(extension, width / 2f, baseline, textPaint); + } + } +} diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/formitem/file/FileFormItemHandler.java b/app/src/main/java/com/huozige/lab/container/offlineform/formitem/file/FileFormItemHandler.java new file mode 100644 index 00000000..e0009ecf --- /dev/null +++ b/app/src/main/java/com/huozige/lab/container/offlineform/formitem/file/FileFormItemHandler.java @@ -0,0 +1,72 @@ +package com.huozige.lab.container.offlineform.formitem.file; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import com.alibaba.fastjson.JSONObject; +import com.huozige.lab.container.R; +import com.huozige.lab.container.offlineform.formitem.OfflineFormItemHandler; +import com.huozige.lab.container.offlineform.formitem.OfflineFormItemJsonHelper; +import com.huozige.lab.container.offlineform.formitem.OfflineFormItemJsonKeys; +import com.huozige.lab.container.offlineform.formitem.OfflineFormItemType; +import com.huozige.lab.container.offlineform.formitem.OfflineFormItemViewType; +import com.huozige.lab.container.offlineform.formitem.ReadOnlyFormItemViews; +import com.huozige.lab.container.offlineform.model.formitem.common.BaseFormItem; +import com.huozige.lab.container.offlineform.model.formitem.file.FileFormItem; +import com.huozige.lab.container.offlineform.model.formitem.file.FileFormItemOptions; +import com.huozige.lab.container.offlineform.model.formitem.common.FormItemInput; +import com.huozige.lab.container.proxy.support.offlinecustomform.viewholder.BaseViewHolder; + +/** + * 处理文件上传的表单项。 + */ +public class FileFormItemHandler implements OfflineFormItemHandler { + @Override + public String getType() { + return OfflineFormItemType.FILE.getValue(); + } + + @Override + public int getViewType() { + return OfflineFormItemViewType.FILE.getValue(); + } + + @Override + public BaseFormItem fromInput(FormItemInput input) { + FileFormItem item = new FileFormItem(getType(), input.itemId, input.title, input.hint, input.required); + FileFormItemOptions options = (FileFormItemOptions) input.options; + if (options != null && options.getFileItemConfig() != null) { + item.setFileItemConfig(options.getFileItemConfig()); + } + item.setValue(input.value); + return item; + } + + @Override + public Class getOptionsClass() { + return FileFormItemOptions.class; + } + + @Override + public JSONObject toJson(BaseFormItem item) { + FileFormItem fileItem = (FileFormItem) item; + JSONObject jsonObject = OfflineFormItemJsonHelper.buildBaseOutput(fileItem); + FileFormItemOptions options = new FileFormItemOptions(); + options.setFileItemConfig(fileItem.getFileItemConfig()); + jsonObject.put(OfflineFormItemJsonKeys.FIELD_OPTIONS, options); + return jsonObject; + } + + @Override + public BaseViewHolder createEditViewHolder(ViewGroup parent) { + View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.custom_form_item_form_file, parent, false); + return new FileViewHolder(view); + } + + @Override + public View createReadOnlyView(Context context, BaseFormItem item, String rawValue, boolean compact) { + return ReadOnlyFormItemViews.createCompactValueView(context, context.getString(R.string.offline_text_file_count, FileFormItem.parseAttachments(rawValue).size())); + } +} diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/formitem/file/FileUploadHost.java b/app/src/main/java/com/huozige/lab/container/offlineform/formitem/file/FileUploadHost.java new file mode 100644 index 00000000..14b04d4c --- /dev/null +++ b/app/src/main/java/com/huozige/lab/container/offlineform/formitem/file/FileUploadHost.java @@ -0,0 +1,13 @@ +package com.huozige.lab.container.offlineform.formitem.file; + +import com.huozige.lab.container.offlineform.formitem.AttachmentCallback; +import com.huozige.lab.container.offlineform.model.formitem.file.FileFormItem; + +/** + * 文件上传回调。 + */ +public interface FileUploadHost { + void uploadFile(FileFormItem item, AttachmentCallback callback); + + void onFilesChanged(FileFormItem item); +} diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/formitem/file/FileViewHolder.java b/app/src/main/java/com/huozige/lab/container/offlineform/formitem/file/FileViewHolder.java new file mode 100644 index 00000000..02dbf23b --- /dev/null +++ b/app/src/main/java/com/huozige/lab/container/offlineform/formitem/file/FileViewHolder.java @@ -0,0 +1,244 @@ +package com.huozige.lab.container.offlineform.formitem.file; + +import android.annotation.SuppressLint; +import android.content.ActivityNotFoundException; +import android.content.Intent; +import android.net.Uri; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.webkit.MimeTypeMap; +import android.widget.Button; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.huozige.lab.container.R; +import com.huozige.lab.container.offlineform.model.formitem.common.AttachmentFormItemValue; +import com.huozige.lab.container.offlineform.model.formitem.common.BaseFormItem; +import com.huozige.lab.container.offlineform.model.formitem.file.FileFormItem; +import com.huozige.lab.container.offlineform.util.Utils; +import com.huozige.lab.container.proxy.support.offlinecustomform.viewholder.BaseViewHolder; +import com.huozige.lab.container.utilities.DeviceUtility; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +/** + * 文件上传控件 + */ +public class FileViewHolder extends BaseViewHolder { + private final TextView tvTitle; + private final TextView tvRequired; + private final RecyclerView fileList; + private final Button btnUploadFile; + private final TextView tvError; + private final FileListAdapter fileListAdapter = new FileListAdapter(); + + private FileFormItem fileItem; + + public FileViewHolder(View itemView) { + super(itemView); + tvTitle = itemView.findViewById(R.id.tv_title); + tvRequired = itemView.findViewById(R.id.tv_required); + fileList = itemView.findViewById(R.id.file_list); + btnUploadFile = itemView.findViewById(R.id.btn_upload_file); + tvError = itemView.findViewById(R.id.tv_error); + setupFileList(); + setupListeners(); + } + + private void setupFileList() { + fileList.setLayoutManager(new LinearLayoutManager(itemView.getContext())); + fileList.setAdapter(fileListAdapter); + fileList.setNestedScrollingEnabled(false); + } + + private void setupListeners() { + btnUploadFile.setOnClickListener(v -> { + if (!canAddFile(true)) { + return; + } + if (!(itemView.getContext() instanceof FileUploadHost)) { + Toast.makeText(itemView.getContext(), R.string.offline_toast_file_upload_not_supported, Toast.LENGTH_SHORT).show(); + return; + } + ((FileUploadHost) itemView.getContext()).uploadFile(fileItem, this::addFiles); + }); + } + + @Override + public void bind(BaseFormItem item) { + bind(item, getAdapterPosition()); + } + + @Override + public void bind(BaseFormItem item, int position) { + fileItem = (FileFormItem) item; + tvTitle.setText(fileItem.getTitle()); + tvRequired.setVisibility(fileItem.isRequired() ? View.VISIBLE : View.GONE); + renderFiles(); + updateErrorState(); + } + + private void addFiles(List files) { + if (files == null || files.isEmpty()) { + return; + } + for (AttachmentFormItemValue file : files) { + fileItem.addFile(file); + } + fileItem.clearError(); + renderFiles(); + updateErrorState(); + } + + //todo 性能优化 + @SuppressLint("NotifyDataSetChanged") + private void renderFiles() { + fileListAdapter.notifyDataSetChanged(); + setActionButtonEnabled(btnUploadFile, canAddFile(false)); + } + + private boolean canAddFile(boolean showMessage) { + if (fileItem == null) { + return false; + } + int maxCount = fileItem.getFileItemConfig() == null ? 0 : fileItem.getFileItemConfig().getMaxCount(); + if (maxCount > 0 && fileItem.getFiles().size() >= maxCount) { + if (showMessage) { + Toast.makeText(itemView.getContext(), itemView.getContext().getString(R.string.offline_toast_max_file_count, maxCount), Toast.LENGTH_SHORT).show(); + } + return false; + } + return true; + } + + private void setActionButtonEnabled(Button button, boolean enabled) { + button.setEnabled(enabled); + button.setAlpha(enabled ? 1.0f : 0.45f); + } + + @Override + public void updateErrorState() { + if (fileItem != null && fileItem.getErrorMessage() != null) { + tvError.setVisibility(View.VISIBLE); + tvError.setText(fileItem.getErrorMessage()); + } else { + tvError.setVisibility(View.GONE); + } + } + + private List getFiles() { + return fileItem == null || fileItem.getFiles() == null + ? new ArrayList<>() + : new ArrayList<>(fileItem.getFiles()); + } + + private class FileListAdapter extends RecyclerView.Adapter { + @NonNull + @Override + public FileCardViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.custom_form_item_file_row, parent, false); + return new FileCardViewHolder(view); + } + + @Override + public void onBindViewHolder(@NonNull FileCardViewHolder holder, int position) { + holder.bind(getFiles().get(position)); + } + + @Override + public int getItemCount() { + return fileItem == null || fileItem.getFiles() == null ? 0 : fileItem.getFiles().size(); + } + } + + private class FileCardViewHolder extends RecyclerView.ViewHolder { + private final FileExtensionIconView fileIconView; + private final TextView fileNameView; + private final TextView fileMetaView; + private final Button deleteButton; + + FileCardViewHolder(@NonNull View itemView) { + super(itemView); + fileIconView = itemView.findViewById(R.id.fileIconView); + fileNameView = itemView.findViewById(R.id.fileNameView); + fileMetaView = itemView.findViewById(R.id.fileMetaView); + deleteButton = itemView.findViewById(R.id.deleteFileButton); + } + + void bind(AttachmentFormItemValue attachment) { + String originalName = attachment.getOriginalName(); + String fileName = attachment.getFileName(); + fileIconView.setExtension(Utils.getExtension(originalName)); + fileNameView.setText(originalName); + File file = Utils.resolveLocalFile(itemView.getContext(), fileItem.getPatternId(), fileName); + fileMetaView.setText(buildFileMeta(originalName, file)); + itemView.setOnClickListener(v -> openFile(originalName, file)); + deleteButton.setOnClickListener(v -> { + OfflineFileHelper.deleteLocalFile(itemView.getContext(), fileItem.getPatternId(), fileName); + fileItem.removeFile(originalName); + renderFiles(); + updateErrorState(); + notifyFilesChanged(); + }); + } + } + + private void openFile(String originalName, File file) { + if (file == null || !file.exists()) { + Toast.makeText(itemView.getContext(), R.string.offline_toast_file_missing, Toast.LENGTH_SHORT).show(); + return; + } + + Uri fileUri = DeviceUtility.pathToUri(file.getPath()); + Intent openIntent = new Intent(Intent.ACTION_VIEW); + openIntent.setDataAndType(fileUri, getMimeType(originalName)); + openIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + try { + Intent chooser = Intent.createChooser(openIntent, itemView.getContext().getString(R.string.offline_chooser_open_file)); + chooser.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + itemView.getContext().startActivity(chooser); + } catch (ActivityNotFoundException e) { + Toast.makeText(itemView.getContext(), R.string.offline_toast_no_app_open_file, Toast.LENGTH_SHORT).show(); + } + } + + private String buildFileMeta(String originalName, File file) { + String extension = Utils.getExtension(originalName); + String size = file != null && file.exists() ? formatSize(file.length()) : itemView.getContext().getString(R.string.offline_text_file_missing); + return extension.isEmpty() ? size : extension.toUpperCase() + " · " + size; + } + + private String getMimeType(String fileName) { + String extension = Utils.getExtension(fileName); + if (extension.isEmpty()) { + return "*/*"; + } + String mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension.toLowerCase(Locale.ROOT)); + return mimeType == null || mimeType.isEmpty() ? "*/*" : mimeType; + } + + @SuppressLint("DefaultLocale") + private String formatSize(long bytes) { + if (bytes < 1024) { + return bytes + " B"; + } + if (bytes < 1024 * 1024) { + return String.format("%.1f KB", bytes / 1024f); + } + return String.format("%.1f MB", bytes / 1024f / 1024f); + } + + private void notifyFilesChanged() { + if (itemView.getContext() instanceof FileUploadHost) { + ((FileUploadHost) itemView.getContext()).onFilesChanged(fileItem); + } + } +} diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/formitem/file/OfflineFileHelper.java b/app/src/main/java/com/huozige/lab/container/offlineform/formitem/file/OfflineFileHelper.java new file mode 100644 index 00000000..1c3f9660 --- /dev/null +++ b/app/src/main/java/com/huozige/lab/container/offlineform/formitem/file/OfflineFileHelper.java @@ -0,0 +1,135 @@ +package com.huozige.lab.container.offlineform.formitem.file; + +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.provider.OpenableColumns; +import android.webkit.MimeTypeMap; + +import com.huozige.lab.container.R; +import com.huozige.lab.container.offlineform.model.formitem.common.AttachmentFormItemValue; +import com.huozige.lab.container.offlineform.model.formitem.file.FileFormItem; +import com.huozige.lab.container.offlineform.model.formitem.file.FileItemConfig; +import com.huozige.lab.container.offlineform.util.Utils; +import com.huozige.lab.container.utilities.StringUtils; + +import java.io.File; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.Locale; +import java.util.Set; +import java.util.UUID; + +public final class OfflineFileHelper { + + private OfflineFileHelper() { + } + + public static AttachmentFormItemValue saveFile(Context context, String patternId, FileFormItem item, Uri sourceUri) throws Exception { + String originalName = getDisplayName(context, sourceUri); + FileItemConfig config = item.getFileItemConfig() == null ? new FileItemConfig() : item.getFileItemConfig(); + validateFile(context, sourceUri, originalName, config); + + File outputFile = createOutputFile(context, patternId, item.getId(), Utils.getExtension(originalName)); + Utils.copyUriToFile( + context, + sourceUri, + outputFile, + context.getString(R.string.offline_error_file_read_failed), + config.getMaxFileSizeKb(), + context.getString(R.string.offline_error_file_size)); + + AttachmentFormItemValue value = new AttachmentFormItemValue(); + value.setOriginalName(originalName); + value.setFileName(outputFile.getName()); + return value; + } + + public static void deleteLocalFile(Context context, String patternId, String fileName) { + File file = Utils.resolveLocalFile(context, patternId, fileName); + if (file != null && file.exists()) { + file.delete(); + } + } + + private static void validateFile(Context context, Uri sourceUri, String originalName, FileItemConfig config) { + if (config.getMaxFileSizeKb() > 0) { + long fileSize = getFileSize(context, sourceUri); + if (fileSize > config.getMaxFileSizeKb() * 1024L) { + throw new IllegalArgumentException(context.getString(R.string.offline_error_file_size_with_name, originalName)); + } + } + + Set allowedExtensions = parseAllowedExtensions(config.getAllowedExtensions()); + if (!allowedExtensions.isEmpty() && !allowedExtensions.contains(Utils.getExtension(originalName).toLowerCase(Locale.ROOT))) { + throw new IllegalArgumentException(context.getString(R.string.offline_error_file_type_not_supported, originalName)); + } + } + + private static Set parseAllowedExtensions(String value) { + Set result = new HashSet<>(); + if (StringUtils.isNullOrBlank(value)) { + return result; + } + String[] parts = value.split(","); + for (String part : parts) { + String extension = part.trim().toLowerCase(Locale.ROOT); + if (extension.startsWith(".")) { + extension = extension.substring(1); + } + if (!extension.isEmpty()) { + result.add(extension); + } + } + return result; + } + + public static String getDisplayName(Context context, Uri sourceUri) { + String displayName = null; + try (Cursor cursor = context.getContentResolver().query(sourceUri, null, null, null, null)) { + if (cursor != null && cursor.moveToFirst()) { + int nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME); + if (nameIndex >= 0) { + displayName = cursor.getString(nameIndex); + } + } + } + if (displayName == null || displayName.isEmpty()) { + displayName = sourceUri.getLastPathSegment(); + } + return displayName == null || displayName.isEmpty() ? context.getString(R.string.offline_text_unnamed_file) : displayName; + } + + private static long getFileSize(Context context, Uri sourceUri) { + try (Cursor cursor = context.getContentResolver().query(sourceUri, null, null, null, null)) { + if (cursor != null && cursor.moveToFirst()) { + int sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE); + if (sizeIndex >= 0) { + return cursor.getLong(sizeIndex); + } + } + } + return 0; + } + + private static File createOutputFile(Context context, String patternId, String fieldId, String extension) { + File dir = Utils.getFilesDir(context, patternId); + dir.mkdirs(); + return new File(dir, Utils.sanitizePathSegment(patternId) + "_" + + Utils.sanitizePathSegment(fieldId) + "_" + + UUID.randomUUID().toString() + "." + Utils.sanitizeExtension(extension, "file")); + } + + public static String[] buildMimeTypes(FileItemConfig config) { + Set mimeTypes = new LinkedHashSet<>(); + Set extensions = parseAllowedExtensions(config == null ? null : config.getAllowedExtensions()); + for (String extension : extensions) { + String mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension); + if (mimeType != null && !mimeType.isEmpty()) { + mimeTypes.add(mimeType); + } + } + return mimeTypes.isEmpty() ? new String[]{"*/*"} : mimeTypes.toArray(new String[0]); + } + +} diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/formitem/image/ImageCaptureHost.java b/app/src/main/java/com/huozige/lab/container/offlineform/formitem/image/ImageCaptureHost.java new file mode 100644 index 00000000..5fe69fbc --- /dev/null +++ b/app/src/main/java/com/huozige/lab/container/offlineform/formitem/image/ImageCaptureHost.java @@ -0,0 +1,19 @@ +package com.huozige.lab.container.offlineform.formitem.image; + +import com.huozige.lab.container.offlineform.formitem.AttachmentCallback; +import com.huozige.lab.container.offlineform.model.formitem.image.ImageFormItem; + +import java.util.ArrayList; + +/** + * 支持拍照和上传图片的接口 + */ +public interface ImageCaptureHost { + void captureImage(ImageFormItem item, AttachmentCallback callback); + + void uploadImage(ImageFormItem item, AttachmentCallback callback); + + void previewImages(ImageFormItem item, ArrayList fileNames, int index); + + void onImagesChanged(ImageFormItem item); +} diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/formitem/image/ImageFormItemHandler.java b/app/src/main/java/com/huozige/lab/container/offlineform/formitem/image/ImageFormItemHandler.java new file mode 100644 index 00000000..6570071a --- /dev/null +++ b/app/src/main/java/com/huozige/lab/container/offlineform/formitem/image/ImageFormItemHandler.java @@ -0,0 +1,95 @@ +package com.huozige.lab.container.offlineform.formitem.image; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import com.alibaba.fastjson.JSONObject; +import com.huozige.lab.container.R; +import com.huozige.lab.container.offlineform.formitem.OfflineFormItemHandler; +import com.huozige.lab.container.offlineform.formitem.OfflineFormItemJsonHelper; +import com.huozige.lab.container.offlineform.formitem.OfflineFormItemJsonKeys; +import com.huozige.lab.container.offlineform.formitem.OfflineFormItemType; +import com.huozige.lab.container.offlineform.formitem.OfflineFormItemViewType; +import com.huozige.lab.container.offlineform.formitem.ReadOnlyFormItemViews; +import com.huozige.lab.container.offlineform.model.formitem.common.AttachmentFormItemValue; +import com.huozige.lab.container.offlineform.model.formitem.common.BaseFormItem; +import com.huozige.lab.container.offlineform.model.formitem.common.FormItemInput; +import com.huozige.lab.container.offlineform.model.formitem.image.ImageFormItem; +import com.huozige.lab.container.offlineform.model.formitem.image.ImageFormItemOptions; +import com.huozige.lab.container.offlineform.model.formitem.image.ImageWatermarkOptions; +import com.huozige.lab.container.proxy.support.offlinecustomform.viewholder.BaseViewHolder; + +import java.util.List; + +public class ImageFormItemHandler implements OfflineFormItemHandler { + @Override + public String getType() { + return OfflineFormItemType.IMAGE.getValue(); + } + + @Override + public int getViewType() { + return OfflineFormItemViewType.IMAGE.getValue(); + } + + @Override + public BaseFormItem fromInput(FormItemInput input) { + ImageFormItem item = new ImageFormItem(getType(), input.itemId, input.title, input.hint, input.required); + ImageFormItemOptions options = (ImageFormItemOptions) input.options; + if (options != null && options.getMaxCount() > 0) { + item.setMaxCount(options.getMaxCount()); + } + if (options != null) { + item.setAllowImageUpload(options.isAllowImageUpload()); + } + if (options != null && options.getCompression() != null) { + item.setCompression(options.getCompression()); + } + if (options != null && options.getWatermark() != null) { + item.setWatermark(options.getWatermark()); + } + item.setValue(input.value); + return item; + } + + @Override + public Class getOptionsClass() { + return ImageFormItemOptions.class; + } + + @Override + public JSONObject toJson(BaseFormItem item) { + ImageFormItem imageItem = (ImageFormItem) item; + JSONObject jsonObject = OfflineFormItemJsonHelper.buildBaseOutput(imageItem); + jsonObject.put(OfflineFormItemJsonKeys.FIELD_OPTIONS, buildOptions(imageItem)); + return jsonObject; + } + + @Override + public BaseViewHolder createEditViewHolder(ViewGroup parent) { + View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.custom_form_item_form_image, parent, false); + return new ImageViewHolder(view); + } + + @Override + public View createReadOnlyView(Context context, BaseFormItem item, String rawValue, boolean compact) { + List images = ImageFormItem.parseImages(rawValue); + return ReadOnlyFormItemViews.createCompactValueView(context, images.isEmpty() ? "" : context.getString(R.string.offline_text_image_count, images.size())); + } + + private JSONObject buildOptions(ImageFormItem item) { + ImageFormItemOptions options = new ImageFormItemOptions(); + options.setMaxCount(item.getMaxCount()); + options.setAllowImageUpload(item.isAllowImageUpload()); + options.setCompression(item.getCompression()); + + ImageWatermarkOptions watermark = item.getWatermark(); + if (watermark != null && (watermark.isEnableTimestamp() || watermark.getItems() != null && !watermark.getItems().isEmpty())) { + options.setWatermark(watermark); + } + return (JSONObject) JSONObject.toJSON(options); + } + +} diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/formitem/image/ImageViewHolder.java b/app/src/main/java/com/huozige/lab/container/offlineform/formitem/image/ImageViewHolder.java new file mode 100644 index 00000000..f4206d0f --- /dev/null +++ b/app/src/main/java/com/huozige/lab/container/offlineform/formitem/image/ImageViewHolder.java @@ -0,0 +1,271 @@ +package com.huozige.lab.container.offlineform.formitem.image; + +import android.net.Uri; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.ImageView; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.GridLayoutManager; +import androidx.recyclerview.widget.ItemTouchHelper; +import androidx.recyclerview.widget.RecyclerView; + +import com.bumptech.glide.Glide; +import com.bumptech.glide.load.engine.DiskCacheStrategy; +import com.huozige.lab.container.R; +import com.huozige.lab.container.offlineform.model.formitem.common.AttachmentFormItemValue; +import com.huozige.lab.container.offlineform.model.formitem.common.BaseFormItem; +import com.huozige.lab.container.offlineform.model.formitem.image.ImageFormItem; +import com.huozige.lab.container.offlineform.util.Utils; +import com.huozige.lab.container.proxy.support.offlinecustomform.viewholder.BaseViewHolder; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; + +import static com.huozige.lab.container.offlineform.util.OfflineFormUiUnitHelper.dp; + +public class ImageViewHolder extends BaseViewHolder { + private final TextView tvTitle; + private final RecyclerView imageList; + private final Button btnCaptureImage; + private final Button btnUploadImage; + private final TextView tvError; + private final TextView tvRequired; + private final ImageListAdapter imageListAdapter = new ImageListAdapter(); + + private ImageFormItem imageItem; + + public ImageViewHolder(View itemView) { + super(itemView); + tvTitle = itemView.findViewById(R.id.tv_title); + imageList = itemView.findViewById(R.id.image_list); + btnCaptureImage = itemView.findViewById(R.id.btn_capture_image); + btnUploadImage = itemView.findViewById(R.id.btn_upload_image); + tvError = itemView.findViewById(R.id.tv_error); + tvRequired = itemView.findViewById(R.id.tv_required); + setupImageList(); + setupListeners(); + } + + private void setupImageList() { + imageList.setLayoutManager(new GridLayoutManager(itemView.getContext(), 2)); + imageList.setAdapter(imageListAdapter); + imageList.setNestedScrollingEnabled(false); + new ItemTouchHelper(new ItemTouchHelper.SimpleCallback( + ItemTouchHelper.UP | ItemTouchHelper.DOWN | ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT, + 0) { + @Override + public boolean onMove(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder source, @NonNull RecyclerView.ViewHolder target) { + int from = source.getBindingAdapterPosition(); + int to = target.getBindingAdapterPosition(); + if (from == RecyclerView.NO_POSITION || to == RecyclerView.NO_POSITION || imageItem == null) { + return false; + } + AttachmentFormItemValue movedImage = imageItem.getImages().remove(from); + imageItem.getImages().add(to, movedImage); + imageListAdapter.notifyItemMoved(from, to); + return true; + } + + @Override + public void onSwiped(@NonNull RecyclerView.ViewHolder viewHolder, int direction) { + } + + @Override + public boolean isLongPressDragEnabled() { + return true; + } + + @Override + public void clearView(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder) { + super.clearView(recyclerView, viewHolder); + notifyImagesChanged(); + } + }).attachToRecyclerView(imageList); + } + + private void setupListeners() { + btnCaptureImage.setOnClickListener(v -> { + if (!canAddImage()) { + return; + } + if (!(itemView.getContext() instanceof ImageCaptureHost)) { + Toast.makeText(itemView.getContext(), R.string.offline_toast_image_capture_not_supported, Toast.LENGTH_SHORT).show(); + return; + } + ((ImageCaptureHost) itemView.getContext()).captureImage(imageItem, this::addImages); + }); + btnUploadImage.setOnClickListener(v -> { + if (!canAddImage()) { + return; + } + if (!(itemView.getContext() instanceof ImageCaptureHost)) { + Toast.makeText(itemView.getContext(), R.string.offline_toast_image_upload_not_supported, Toast.LENGTH_SHORT).show(); + return; + } + ((ImageCaptureHost) itemView.getContext()).uploadImage(imageItem, this::addImages); + }); + } + + @Override + public void bind(BaseFormItem item) { + bind(item, getAdapterPosition()); + } + + @Override + public void bind(BaseFormItem item, int position) { + imageItem = (ImageFormItem) item; + tvTitle.setText(imageItem.getTitle()); + tvRequired.setVisibility(imageItem.isRequired() ? View.VISIBLE : View.GONE); + btnUploadImage.setVisibility(imageItem.isAllowImageUpload() ? View.VISIBLE : View.GONE); + renderImages(); + updateErrorState(); + } + + private void addImages(List images) { + if (images == null || images.isEmpty()) { + return; + } + for (AttachmentFormItemValue image : images) { + imageItem.addImage(image); + } + imageItem.clearError(); + renderImages(); + updateErrorState(); + } + + private void renderImages() { + imageListAdapter.notifyDataSetChanged(); + boolean canAdd = imageItem.getMaxCount() <= 0 || imageItem.getImages().size() < imageItem.getMaxCount(); + setActionButtonEnabled(btnCaptureImage, canAdd); + setActionButtonEnabled(btnUploadImage, canAdd); + } + + private void setActionButtonEnabled(Button button, boolean enabled) { + button.setEnabled(enabled); + button.setAlpha(enabled ? 1.0f : 0.45f); + } + + private boolean canAddImage() { + if (imageItem == null) { + return false; + } + if (imageItem.getMaxCount() > 0 && imageItem.getImages().size() >= imageItem.getMaxCount()) { + Toast.makeText(itemView.getContext(), itemView.getContext().getString(R.string.offline_toast_max_image_count, imageItem.getMaxCount()), Toast.LENGTH_SHORT).show(); + return false; + } + return true; + } + + private void openPreview(int index) { + if (imageItem == null || imageItem.getImages() == null || imageItem.getImages().isEmpty()) { + return; + } + if (index == RecyclerView.NO_POSITION) { + return; + } + if (!(itemView.getContext() instanceof ImageCaptureHost)) { + return; + } + ArrayList fileNames = new ArrayList<>(); + int previewIndex = 0; + for (int i = 0; i < imageItem.getImages().size(); i++) { + AttachmentFormItemValue image = imageItem.getImages().get(i); + if (image != null && image.getFileName() != null && !image.getFileName().isEmpty()) { + if (i < index) { + previewIndex++; + } + fileNames.add(image.getFileName()); + } + } + ((ImageCaptureHost) itemView.getContext()).previewImages(imageItem, fileNames, previewIndex); + } + + @Override + public void updateErrorState() { + if (imageItem != null && imageItem.getErrorMessage() != null) { + tvError.setVisibility(View.VISIBLE); + tvError.setText(imageItem.getErrorMessage()); + } else { + tvError.setVisibility(View.GONE); + } + } + + private class ImageListAdapter extends RecyclerView.Adapter { + @NonNull + @Override + public ImageCardViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.custom_form_item_image_card, parent, false); + return new ImageCardViewHolder(view); + } + + @Override + public void onBindViewHolder(@NonNull ImageCardViewHolder holder, int position) { + holder.bind(imageItem.getImages().get(position), position); + } + + @Override + public int getItemCount() { + return imageItem == null || imageItem.getImages() == null ? 0 : imageItem.getImages().size(); + } + } + + private class ImageCardViewHolder extends RecyclerView.ViewHolder { + private final ImageView preview; + private final TextView errorView; + private final Button deleteButton; + + ImageCardViewHolder(@NonNull View itemView) { + super(itemView); + preview = itemView.findViewById(R.id.imagePreview); + errorView = itemView.findViewById(R.id.imageErrorView); + deleteButton = itemView.findViewById(R.id.deleteImageButton); + } + + void bind(AttachmentFormItemValue image, int position) { + errorView.setVisibility(View.GONE); + preview.setVisibility(View.VISIBLE); + File imageFile = Utils.resolveLocalFile(itemView.getContext(), imageItem.getPatternId(), image == null ? null : image.getFileName()); + if (imageFile != null && imageFile.exists()) { + int previewSize = dp(itemView.getContext(), 140); + Glide.with(itemView) + .load(Uri.fromFile(imageFile)) + .override(previewSize, previewSize) + .centerCrop() + .diskCacheStrategy(DiskCacheStrategy.NONE) + .dontAnimate() + .error(R.drawable.offline_image_error_bg) + .into(preview); + } else { + preview.setVisibility(View.GONE); + errorView.setVisibility(View.VISIBLE); + } + + preview.setOnClickListener(v -> openPreview(getBindingAdapterPosition())); + errorView.setOnClickListener(v -> openPreview(getBindingAdapterPosition())); + deleteButton.setOnClickListener(v -> { + int currentPosition = getBindingAdapterPosition(); + if (currentPosition == RecyclerView.NO_POSITION) { + return; + } + AttachmentFormItemValue currentImage = imageItem.getImages().get(currentPosition); + OfflineImageFileHelper.deleteLocalFile(itemView.getContext(), imageItem.getPatternId(), currentImage.getFileName()); + imageItem.removeImage(currentImage); + renderImages(); + updateErrorState(); + notifyImagesChanged(); + }); + } + } + + private void notifyImagesChanged() { + if (itemView.getContext() instanceof ImageCaptureHost) { + ((ImageCaptureHost) itemView.getContext()).onImagesChanged(imageItem); + } + } +} diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/formitem/image/OfflineImageFileHelper.java b/app/src/main/java/com/huozige/lab/container/offlineform/formitem/image/OfflineImageFileHelper.java new file mode 100644 index 00000000..2e71b291 --- /dev/null +++ b/app/src/main/java/com/huozige/lab/container/offlineform/formitem/image/OfflineImageFileHelper.java @@ -0,0 +1,313 @@ +package com.huozige.lab.container.offlineform.formitem.image; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Matrix; +import android.graphics.Paint; +import android.media.ExifInterface; +import android.net.Uri; +import android.webkit.MimeTypeMap; + +import com.huozige.lab.container.R; +import com.huozige.lab.container.offlineform.model.formitem.common.AttachmentFormItemValue; +import com.huozige.lab.container.offlineform.model.formitem.image.ImageCompressionOptions; +import com.huozige.lab.container.offlineform.model.formitem.image.ImageFormItem; +import com.huozige.lab.container.offlineform.model.formitem.image.ImageWatermarkField; +import com.huozige.lab.container.offlineform.model.formitem.image.ImageWatermarkOptions; +import com.huozige.lab.container.offlineform.util.Utils; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.InputStream; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; + +public final class OfflineImageFileHelper { + + private static final DateTimeFormatter WATERMARK_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm:ss"); + private static final Object FILE_NAME_LOCK = new Object(); + private static long lastImageFileTimestamp; + private static int imageFileSequence; + + private OfflineImageFileHelper() { + } + + public static AttachmentFormItemValue saveImage(Context context, String patternId, ImageFormItem item, Uri sourceUri) throws Exception { + ImageCompressionOptions options = item.getCompression() == null ? new ImageCompressionOptions() : item.getCompression(); + int rotationDegrees = readExifRotationDegrees(context, sourceUri); + boolean shouldRewriteImage = options.isEnableCompression() || rotationDegrees != 0; + File outputFile = createOutputFile(context, patternId, shouldRewriteImage ? "jpg" : getSourceExtension(context, sourceUri)); + if (shouldRewriteImage) { + Bitmap source = decodeBitmap(context, sourceUri, options.getMaxLongEdge()); + Bitmap rotated = null; + try { + rotated = rotateBitmap(source, rotationDegrees); + compressToFile(rotated, outputFile, options); + } finally { + if (rotated != null && rotated != source) { + rotated.recycle(); + } + source.recycle(); + } + } else { + Utils.copyUriToFile( + context, + sourceUri, + outputFile, + context.getString(R.string.offline_error_image_read_failed), + 0, + null); + } + + AttachmentFormItemValue value = new AttachmentFormItemValue(); + value.setFileName(outputFile.getName()); + return value; + } + + public static void deleteLocalFile(Context context, String patternId, String fileName) { + File file = Utils.resolveLocalFile(context, patternId, fileName); + if (file != null && file.exists()) { + file.delete(); + } + } + + private static Bitmap decodeBitmap(Context context, Uri sourceUri, int maxLongEdge) throws Exception { + BitmapFactory.Options bounds = new BitmapFactory.Options(); + bounds.inJustDecodeBounds = true; + try (InputStream input = context.getContentResolver().openInputStream(sourceUri)) { + BitmapFactory.decodeStream(input, null, bounds); + } + + BitmapFactory.Options options = new BitmapFactory.Options(); + options.inSampleSize = calculateInSampleSize(bounds, maxLongEdge); + try (InputStream input = context.getContentResolver().openInputStream(sourceUri)) { + Bitmap bitmap = BitmapFactory.decodeStream(input, null, options); + if (bitmap == null) { + throw new IllegalArgumentException(context.getString(R.string.offline_error_image_read_failed)); + } + return bitmap; + } + } + + private static int readExifRotationDegrees(Context context, Uri sourceUri) { + try (InputStream input = context.getContentResolver().openInputStream(sourceUri)) { + if (input == null) { + return 0; + } + ExifInterface exif = new ExifInterface(input); + int orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL); + switch (orientation) { + case ExifInterface.ORIENTATION_ROTATE_90: + return 90; + case ExifInterface.ORIENTATION_ROTATE_180: + return 180; + case ExifInterface.ORIENTATION_ROTATE_270: + return 270; + default: + return 0; + } + } catch (Exception ignored) { + return 0; + } + } + + private static Bitmap rotateBitmap(Bitmap source, int degrees) { + if (source == null || degrees == 0) { + return source; + } + Matrix matrix = new Matrix(); + matrix.postRotate(degrees); + return Bitmap.createBitmap(source, 0, 0, source.getWidth(), source.getHeight(), matrix, true); + } + + private static int calculateInSampleSize(BitmapFactory.Options options, int maxLongEdge) { + if (options.outWidth <= 0 || options.outHeight <= 0) { + return 1; + } + if (maxLongEdge <= 0 || options.outWidth <= maxLongEdge && options.outHeight <= maxLongEdge) { + return 1; + } + int sampleSize = 1; + int width = options.outWidth; + int height = options.outHeight; + while (width / sampleSize > maxLongEdge || height / sampleSize > maxLongEdge) { + sampleSize *= 2; + } + return sampleSize; + } + + private static File createOutputFile(Context context, String patternId, String extension) { + File dir = Utils.getFilesDir(context, patternId); + dir.mkdirs(); + String sanitizedExtension = Utils.sanitizeExtension(extension, "jpg"); + synchronized (FILE_NAME_LOCK) { + while (true) { + long timestamp = System.currentTimeMillis(); + if (timestamp == lastImageFileTimestamp) { + imageFileSequence++; + } else { + lastImageFileTimestamp = timestamp; + imageFileSequence = 0; + } + String suffix = imageFileSequence == 0 ? "" : "_" + imageFileSequence; + File file = new File(dir, timestamp + suffix + "." + sanitizedExtension); + if (!file.exists()) { + return file; + } + } + } + } + + private static void compressToFile(Bitmap bitmap, File outputFile, ImageCompressionOptions options) throws Exception { + int quality = normalizeQuality(options.getJpegQuality()); + int minQuality = normalizeQuality(options.getMinQuality()); + int maxFileSizeKb = Math.max(0, options.getMaxFileSizeKb()); + do { + try (FileOutputStream output = new FileOutputStream(outputFile)) { + bitmap.compress(Bitmap.CompressFormat.JPEG, quality, output); + } + if (maxFileSizeKb <= 0 || outputFile.length() <= maxFileSizeKb * 1024L || quality <= minQuality) { + return; + } + quality = Math.max(minQuality, quality - 10); + } while (true); + } + + public static void writeWatermarkToFile(Context context, Uri sourceUri, File outputFile, List watermarkLines) throws Exception { + writeWatermarkToFile(context, sourceUri, outputFile, watermarkLines, new ImageCompressionOptions()); + } + + private static void writeWatermarkToFile(Context context, Uri sourceUri, File outputFile, List watermarkLines, ImageCompressionOptions options) throws Exception { + if (watermarkLines == null || watermarkLines.isEmpty()) { + return; + } + int rotationDegrees = readExifRotationDegrees(context, sourceUri); + Bitmap source = decodeBitmap(context, sourceUri, options.isEnableCompression() ? options.getMaxLongEdge() : 0); + Bitmap rotated = null; + Bitmap watermarked = null; + try { + rotated = rotateBitmap(source, rotationDegrees); + watermarked = addWatermark(rotated, watermarkLines); + saveWatermarkedToFile(watermarked, outputFile, options); + } finally { + if (watermarked != null && watermarked != rotated) { + watermarked.recycle(); + } + if (rotated != null && rotated != source) { + rotated.recycle(); + } + source.recycle(); + } + } + + private static Bitmap addWatermark(Bitmap source, List lines) { + Bitmap result = source.copy(Bitmap.Config.ARGB_8888, true); + Canvas canvas = new Canvas(result); + Paint textPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + textPaint.setColor(Color.WHITE); + textPaint.setTextSize(Math.max(result.getWidth() / 24f, 28f)); + textPaint.setShadowLayer(4f, 1f, 1f, Color.BLACK); + + float padding = Math.max(result.getWidth() / 40f, 24f); + float textWidth = getMaxTextWidth(textPaint, lines); + float maxTextWidth = result.getWidth() - padding * 2; + if (textWidth > maxTextWidth && textWidth > 0) { + textPaint.setTextSize(textPaint.getTextSize() * maxTextWidth / textWidth); + } + + Paint backgroundPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + backgroundPaint.setColor(Color.argb(110, 0, 0, 0)); + Paint.FontMetrics metrics = textPaint.getFontMetrics(); + float lineHeight = metrics.descent - metrics.ascent; + float backgroundHeight = lineHeight * lines.size() + padding; + canvas.drawRect(0, result.getHeight() - backgroundHeight, result.getWidth(), result.getHeight(), backgroundPaint); + float baseline = result.getHeight() - backgroundHeight + padding / 2f - metrics.ascent; + for (String line : lines) { + canvas.drawText(line, padding, baseline, textPaint); + baseline += lineHeight; + } + return result; + } + + private static float getMaxTextWidth(Paint paint, List lines) { + float maxWidth = 0; + for (String line : lines) { + maxWidth = Math.max(maxWidth, paint.measureText(line)); + } + return maxWidth; + } + + private static void saveWatermarkedToFile(Bitmap bitmap, File outputFile, ImageCompressionOptions options) throws Exception { + if (options.isEnableCompression()) { + compressToFile(bitmap, outputFile, options); + return; + } + try (FileOutputStream output = new FileOutputStream(outputFile)) { + bitmap.compress(Bitmap.CompressFormat.JPEG, 100, output); + } + } + + public static ArrayList buildWatermarkLines(ImageFormItem item) { + ArrayList lines = buildWatermarkCustomLines(item); + if (isTimestampWatermarkEnabled(item)) { + lines.add(LocalDateTime.now().format(WATERMARK_TIME_FORMATTER)); + } + return lines; + } + + private static ArrayList buildWatermarkCustomLines(ImageFormItem item) { + ImageWatermarkOptions watermark = item == null ? null : item.getWatermark(); + ArrayList lines = new ArrayList<>(); + if (watermark == null) { + return lines; + } + if (watermark.getItems() != null) { + for (ImageWatermarkField itemField : watermark.getItems()) { + String line = buildWatermarkCustomLine(itemField); + if (!line.isEmpty()) { + lines.add(line); + } + } + } + return lines; + } + + private static boolean isTimestampWatermarkEnabled(ImageFormItem item) { + ImageWatermarkOptions watermark = item == null ? null : item.getWatermark(); + return watermark != null && watermark.isEnableTimestamp(); + } + + private static String buildWatermarkCustomLine(ImageWatermarkField item) { + if (item == null) { + return ""; + } + String key = normalizeWatermarkText(item.getKey()); + String value = normalizeWatermarkText(item.getValue()); + if (key.isEmpty() && value.isEmpty()) { + return ""; + } + return key + ":" + value; + } + + private static String normalizeWatermarkText(String value) { + return value == null ? "" : value.trim(); + } + + private static int normalizeQuality(int quality) { + if (quality <= 0) { + return ImageCompressionOptions.DEFAULT_JPEG_QUALITY; + } + return Math.min(100, Math.max(1, quality)); + } + + private static String getSourceExtension(Context context, Uri sourceUri) { + String extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(context.getContentResolver().getType(sourceUri)); + return extension == null || extension.isEmpty() ? "jpg" : extension; + } + +} diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/formitem/image/OfflineImagePreviewActivity.java b/app/src/main/java/com/huozige/lab/container/offlineform/formitem/image/OfflineImagePreviewActivity.java new file mode 100644 index 00000000..fd6ac4ee --- /dev/null +++ b/app/src/main/java/com/huozige/lab/container/offlineform/formitem/image/OfflineImagePreviewActivity.java @@ -0,0 +1,213 @@ +package com.huozige.lab.container.offlineform.formitem.image; + +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; +import androidx.viewpager2.widget.ViewPager2; + +import com.bumptech.glide.Glide; +import com.huozige.lab.container.R; +import com.huozige.lab.container.offlineform.util.Utils; +import com.huozige.lab.container.proxy.support.BaseActivityNoActionBar; + +import java.io.File; +import java.util.ArrayList; + +import io.getstream.photoview.PhotoView; + +public class OfflineImagePreviewActivity extends BaseActivityNoActionBar { + private static final String EXTRA_PATTERN_ID = "patternId"; + public static final String EXTRA_FILE_NAMES = "fileNames"; + private static final String EXTRA_INDEX = "index"; + + private ViewPager2 viewPager; + private TextView indexTextView; + private PreviewAdapter adapter; + private String patternId; + private ArrayList fileNames = new ArrayList<>(); + + public static Intent createIntent(Context context, String patternId, ArrayList fileNames, int index) { + Intent intent = new Intent(context, OfflineImagePreviewActivity.class); + intent.putExtra(EXTRA_PATTERN_ID, patternId); + intent.putStringArrayListExtra(EXTRA_FILE_NAMES, fileNames); + intent.putExtra(EXTRA_INDEX, index); + return intent; + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.offline_image_preview_activity); + + patternId = getIntent().getStringExtra(EXTRA_PATTERN_ID); + ArrayList inputFileNames = getIntent().getStringArrayListExtra(EXTRA_FILE_NAMES); + if (inputFileNames != null) { + fileNames = inputFileNames; + } + if (fileNames.isEmpty()) { + finish(); + return; + } + + int initialIndex = Math.max(0, Math.min(getIntent().getIntExtra(EXTRA_INDEX, 0), fileNames.size() - 1)); + indexTextView = findViewById(R.id.previewIndexTextView); + viewPager = findViewById(R.id.previewViewPager); + adapter = new PreviewAdapter(); + viewPager.setAdapter(adapter); + viewPager.setCurrentItem(initialIndex, false); + viewPager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() { + @Override + public void onPageSelected(int position) { + updateIndex(position); + } + }); + + findViewById(R.id.previewBackButton).setOnClickListener(v -> finish()); + findViewById(R.id.previewRotateLeftButton).setOnClickListener(v -> adapter.rotateCurrent(-90)); + findViewById(R.id.previewResetButton).setOnClickListener(v -> adapter.resetCurrent()); + findViewById(R.id.previewRotateRightButton).setOnClickListener(v -> adapter.rotateCurrent(90)); + findViewById(R.id.previewDeleteButton).setOnClickListener(v -> deleteCurrentImage()); + + updateIndex(initialIndex); + } + + private void updateIndex(int index) { + indexTextView.setText((index + 1) + " / " + fileNames.size()); + } + + private void deleteCurrentImage() { + int position = viewPager.getCurrentItem(); + if (position < 0 || position >= fileNames.size()) { + return; + } + String fileName = fileNames.remove(position); + OfflineImageFileHelper.deleteLocalFile(this, patternId, fileName); + setResultChanged(); + if (fileNames.isEmpty()) { + finish(); + return; + } + adapter.remove(position); + int nextPosition = Math.min(position, fileNames.size() - 1); + viewPager.setCurrentItem(nextPosition, false); + updateIndex(nextPosition); + } + + private void setResultChanged() { + Intent data = new Intent(); + data.putStringArrayListExtra(EXTRA_FILE_NAMES, new ArrayList<>(fileNames)); + setResult(RESULT_OK, data); + } + + private class PreviewAdapter extends RecyclerView.Adapter { + private final ArrayList rotations = new ArrayList<>(); + private PreviewViewHolder currentHolder; + + PreviewAdapter() { + for (int i = 0; i < fileNames.size(); i++) { + rotations.add(0.0f); + } + } + + @NonNull + @Override + public PreviewViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + FrameLayout container = new FrameLayout(parent.getContext()); + container.setLayoutParams(new RecyclerView.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT)); + + PhotoView photoView = new PhotoView(parent.getContext()); + photoView.setLayoutParams(new FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT)); + photoView.setMaximumScale(6.0f); + container.addView(photoView); + return new PreviewViewHolder(container, photoView); + } + + @Override + public void onBindViewHolder(@NonNull PreviewViewHolder holder, int position) { + if (position == viewPager.getCurrentItem()) { + currentHolder = holder; + } + holder.photoView.setRotationTo(rotations.get(position)); + holder.photoView.setScale(1.0f, false); + + File imageFile = Utils.resolveLocalFile( + OfflineImagePreviewActivity.this, + patternId, + fileNames.get(position)); + if (imageFile == null || !imageFile.exists()) { + holder.photoView.setImageDrawable(null); + Toast.makeText(OfflineImagePreviewActivity.this, R.string.offline_toast_image_file_missing, Toast.LENGTH_SHORT).show(); + return; + } + Glide.with(holder.photoView) + .load(Uri.fromFile(imageFile)) + .fitCenter() + .into(holder.photoView); + } + + @Override + public void onViewAttachedToWindow(@NonNull PreviewViewHolder holder) { + super.onViewAttachedToWindow(holder); + if (holder.getBindingAdapterPosition() == viewPager.getCurrentItem()) { + currentHolder = holder; + } + } + + @Override + public int getItemCount() { + return fileNames.size(); + } + + void remove(int position) { + rotations.remove(position); + notifyItemRemoved(position); + notifyItemRangeChanged(position, getItemCount() - position); + } + + void rotateCurrent(float degrees) { + int position = viewPager.getCurrentItem(); + rotations.set(position, rotations.get(position) + degrees); + PreviewViewHolder holder = findCurrentHolder(); + if (holder != null) { + holder.photoView.setRotationTo(rotations.get(position)); + } + } + + void resetCurrent() { + int position = viewPager.getCurrentItem(); + rotations.set(position, 0.0f); + PreviewViewHolder holder = findCurrentHolder(); + if (holder != null) { + holder.photoView.setRotationTo(0); + holder.photoView.setScale(1.0f, true); + } + } + + private PreviewViewHolder findCurrentHolder() { + return currentHolder != null && currentHolder.getBindingAdapterPosition() == viewPager.getCurrentItem() + ? currentHolder + : null; + } + } + + private static class PreviewViewHolder extends RecyclerView.ViewHolder { + private final PhotoView photoView; + + PreviewViewHolder(@NonNull FrameLayout itemView, PhotoView photoView) { + super(itemView); + this.photoView = photoView; + } + } +} diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/formitem/list/ListFormItemHandler.java b/app/src/main/java/com/huozige/lab/container/offlineform/formitem/list/ListFormItemHandler.java new file mode 100644 index 00000000..d903e3bf --- /dev/null +++ b/app/src/main/java/com/huozige/lab/container/offlineform/formitem/list/ListFormItemHandler.java @@ -0,0 +1,91 @@ +package com.huozige.lab.container.offlineform.formitem.list; + +import android.content.Context; +import android.view.View; +import android.view.ViewGroup; + +import com.alibaba.fastjson.JSONObject; +import com.huozige.lab.container.R; +import com.huozige.lab.container.offlineform.formitem.OfflineFormItemHandler; +import com.huozige.lab.container.offlineform.formitem.OfflineFormItemJsonHelper; +import com.huozige.lab.container.offlineform.formitem.OfflineFormItemJsonKeys; +import com.huozige.lab.container.offlineform.formitem.OfflineFormItemType; +import com.huozige.lab.container.offlineform.formitem.OfflineFormItemViewType; +import com.huozige.lab.container.offlineform.formitem.ReadOnlyFormItemViews; +import com.huozige.lab.container.offlineform.model.OfflineFormNodeFactory; +import com.huozige.lab.container.offlineform.model.formitem.common.BaseFormItem; +import com.huozige.lab.container.offlineform.model.formitem.common.FormItemInput; +import com.huozige.lab.container.offlineform.model.formitem.list.ListFormItem; +import com.huozige.lab.container.offlineform.model.formitem.list.ListFormItemOptions; +import com.huozige.lab.container.proxy.support.offlinecustomform.viewholder.BaseViewHolder; +import com.huozige.lab.container.utilities.StringUtils; + +public class ListFormItemHandler implements OfflineFormItemHandler { + @Override + public String getType() { + return OfflineFormItemType.LIST.getValue(); + } + + @Override + public int getViewType() { + return OfflineFormItemViewType.LIST.getValue(); + } + + @Override + public BaseFormItem fromInput(FormItemInput input) { + ListFormItem item = new ListFormItem(getType(), input.itemId, input.title, input.hint, input.required); + ListFormItemOptions options = (ListFormItemOptions) input.options; + if (options != null) { + item.setMinCount(options.getMinCount()); + item.setMaxCount(options.getMaxCount()); + item.setDefaultCount(options.getDefaultCount()); + item.setAddButtonText(options.getAddButtonText()); + item.setItemTitle(options.getItemTitle()); + item.setTemplateInputs(options.resolveTemplateNodes()); + item.setTemplateNodes(OfflineFormNodeFactory.buildNodes(options.resolveTemplateNodes())); + } + if (StringUtils.isNullOrBlank(input.value)) { + item.ensureDefaultRows(); + } else { + item.setValue(input.value); + } + return item; + } + + @Override + public Class getOptionsClass() { + return ListFormItemOptions.class; + } + + @Override + public JSONObject toJson(BaseFormItem item) { + ListFormItem listItem = (ListFormItem) item; + JSONObject jsonObject = OfflineFormItemJsonHelper.buildBaseOutput(listItem); + jsonObject.put(OfflineFormItemJsonKeys.FIELD_OPTIONS, buildOptions(listItem)); + return jsonObject; + } + + @Override + public BaseViewHolder createEditViewHolder(ViewGroup parent) { + return new ListViewHolder(ListViewHolder.createView(parent)); + } + + @Override + public View createReadOnlyView(Context context, BaseFormItem item, String rawValue, boolean compact) { + int count = ListFormItem.parseRows(rawValue).size(); + return ReadOnlyFormItemViews.createCompactValueView( + context, + count == 0 ? "" : context.getString(R.string.offline_text_list_count, count)); + } + + private JSONObject buildOptions(ListFormItem item) { + ListFormItemOptions options = new ListFormItemOptions(); + options.setMinCount(item.getMinCount()); + options.setMaxCount(item.getMaxCount()); + options.setDefaultCount(item.getDefaultCount()); + options.setAddButtonText(item.getAddButtonText()); + options.setItemTitle(item.getItemTitle()); + options.setChildren(item.getTemplateInputs()); + return (JSONObject) JSONObject.toJSON(options); + } +} diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/formitem/list/ListViewHolder.java b/app/src/main/java/com/huozige/lab/container/offlineform/formitem/list/ListViewHolder.java new file mode 100644 index 00000000..3fdece67 --- /dev/null +++ b/app/src/main/java/com/huozige/lab/container/offlineform/formitem/list/ListViewHolder.java @@ -0,0 +1,326 @@ +package com.huozige.lab.container.offlineform.formitem.list; + +import android.graphics.Typeface; +import android.graphics.drawable.GradientDrawable; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.appcompat.widget.AppCompatImageView; + +import com.huozige.lab.container.R; +import com.huozige.lab.container.offlineform.formitem.OfflineFormItemHandler; +import com.huozige.lab.container.offlineform.formitem.OfflineFormItemRegistry; +import com.huozige.lab.container.offlineform.model.OfflineFormNode; +import com.huozige.lab.container.offlineform.model.formitem.common.BaseFormItem; +import com.huozige.lab.container.offlineform.model.formitem.list.ListFormItem; +import com.huozige.lab.container.proxy.support.offlinecustomform.viewholder.BaseViewHolder; +import com.huozige.lab.container.utilities.StringUtils; + +import java.util.List; + +import static com.huozige.lab.container.offlineform.util.OfflineFormUiUnitHelper.dp; + +public class ListViewHolder extends BaseViewHolder { + private final LinearLayout rootLayout; + private final TextView titleView; + private final TextView requiredView; + private final LinearLayout rowsLayout; + private final Button addButton; + private final TextView errorView; + + private ListFormItem listItem; + + public ListViewHolder(View itemView) { + super(itemView); + rootLayout = (LinearLayout) itemView; + titleView = new TextView(itemView.getContext()); + requiredView = new TextView(itemView.getContext()); + rowsLayout = new LinearLayout(itemView.getContext()); + addButton = new Button(itemView.getContext()); + errorView = new TextView(itemView.getContext()); + setupView(); + } + + public static View createView(ViewGroup parent) { + LinearLayout root = new LinearLayout(parent.getContext()); + root.setOrientation(LinearLayout.VERTICAL); + root.setLayoutParams(new ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT)); + return root; + } + + private void setupView() { + LinearLayout titleRow = new LinearLayout(itemView.getContext()); + titleRow.setOrientation(LinearLayout.HORIZONTAL); + titleRow.setGravity(Gravity.CENTER_VERTICAL); + + titleView.setTextSize(15); + titleView.setTextColor(itemView.getContext().getColor(android.R.color.black)); + titleView.setTypeface(null, Typeface.BOLD); + titleRow.addView(titleView, new LinearLayout.LayoutParams( + 0, + ViewGroup.LayoutParams.WRAP_CONTENT, + 1)); + + requiredView.setText("*"); + requiredView.setTextSize(16); + requiredView.setTextColor(itemView.getContext().getColor(android.R.color.holo_red_dark)); + titleRow.addView(requiredView, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT)); + + rowsLayout.setOrientation(LinearLayout.VERTICAL); + + addButton.setAllCaps(false); + addButton.setTextColor(itemView.getContext().getColor(R.color.huozige_blue)); + addButton.setBackgroundResource(R.drawable.offline_list_add_button_bg); + addButton.setOnClickListener(v -> addRow()); + + errorView.setTextColor(itemView.getContext().getColor(android.R.color.holo_red_dark)); + errorView.setTextSize(12); + errorView.setPadding(0, dp(itemView.getContext(), 4), 0, 0); + + rootLayout.addView(titleRow, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT)); + rootLayout.addView(rowsLayout, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT)); + LinearLayout.LayoutParams buttonParams = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT); + buttonParams.setMargins(0, dp(itemView.getContext(), 6), 0, 0); + rootLayout.addView(addButton, buttonParams); + rootLayout.addView(errorView, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT)); + } + + @Override + public void bind(BaseFormItem item) { + bind(item, getAdapterPosition()); + } + + @Override + public void bind(BaseFormItem item, int position) { + listItem = (ListFormItem) item; + titleView.setText(listItem.getTitle()); + requiredView.setVisibility(listItem.isRequired() ? View.VISIBLE : View.GONE); + addButton.setText(resolveAddButtonText()); + renderRows(); + updateErrorState(); + } + + @Override + public void updateErrorState() { + if (listItem != null && listItem.getErrorMessage() != null) { + errorView.setVisibility(View.VISIBLE); + errorView.setText(listItem.getErrorMessage()); + } else { + errorView.setVisibility(View.GONE); + } + } + + private void addRow() { + if (listItem == null) { + return; + } + if (listItem.addRow() == null) { + Toast.makeText( + itemView.getContext(), + listItem.getTitle() + "\u6700\u591a\u53ea\u80fd" + listItem.getMaxCount() + "\u9879", + Toast.LENGTH_SHORT).show(); + return; + } + listItem.clearError(); + renderRows(); + updateErrorState(); + } + + private void renderRows() { + rowsLayout.removeAllViews(); + if (listItem == null) { + return; + } + List rows = listItem.getRows(); + for (int i = 0; i < rows.size(); i++) { + rowsLayout.addView(createRowView(rows.get(i), i), createRowLayoutParams()); + } + addButton.setEnabled(listItem.canAddRow()); + addButton.setAlpha(listItem.canAddRow() ? 1.0f : 0.45f); + } + + private LinearLayout.LayoutParams createRowLayoutParams() { + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT); + params.setMargins(0, dp(itemView.getContext(), 8), 0, 0); + return params; + } + + private View createRowView(ListFormItem.ListFormItemRow row, int index) { + LinearLayout rowLayout = new LinearLayout(itemView.getContext()); + rowLayout.setOrientation(LinearLayout.VERTICAL); + rowLayout.setPadding(dp(itemView.getContext(), 10), dp(itemView.getContext(), 8), dp(itemView.getContext(), 10), dp(itemView.getContext(), 10)); + rowLayout.setBackground(createRowBackground()); + + LinearLayout header = new LinearLayout(itemView.getContext()); + header.setOrientation(LinearLayout.HORIZONTAL); + header.setGravity(Gravity.CENTER_VERTICAL); + + TextView rowTitle = new TextView(itemView.getContext()); + rowTitle.setText(resolveRowTitle(index)); + rowTitle.setTextSize(14); + rowTitle.setTypeface(null, Typeface.BOLD); + rowTitle.setTextColor(itemView.getContext().getColor(R.color.offline_form_text_title)); + header.addView(rowTitle, new LinearLayout.LayoutParams( + 0, + ViewGroup.LayoutParams.WRAP_CONTENT, + 1)); + + ImageView deleteAction = new AppCompatImageView(itemView.getContext()); + deleteAction.setImageResource(R.drawable.ic_delete_image); + deleteAction.setColorFilter(itemView.getContext().getColor(R.color.red)); + deleteAction.setBackgroundResource(R.drawable.offline_list_delete_action_bg); + deleteAction.setPadding(dp(itemView.getContext(), 6), dp(itemView.getContext(), 6), dp(itemView.getContext(), 6), dp(itemView.getContext(), 6)); + deleteAction.setContentDescription(itemView.getContext().getString(R.string.offline_button_delete)); + deleteAction.setEnabled(listItem.canRemoveRow()); + deleteAction.setAlpha(listItem.canRemoveRow() ? 1.0f : 0.45f); + deleteAction.setOnClickListener(v -> { + if (listItem.removeRow(index)) { + renderRows(); + updateErrorState(); + } + }); + header.addView(deleteAction, new LinearLayout.LayoutParams( + dp(itemView.getContext(), 32), + dp(itemView.getContext(), 32))); + LinearLayout.LayoutParams deleteParams = (LinearLayout.LayoutParams) deleteAction.getLayoutParams(); + deleteParams.leftMargin = dp(itemView.getContext(), 8); + deleteAction.setLayoutParams(deleteParams); + + LinearLayout content = new LinearLayout(itemView.getContext()); + content.setOrientation(LinearLayout.VERTICAL); + renderNodes(content, row.getNodes(), 0); + + rowLayout.addView(header, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT)); + rowLayout.addView(content, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT)); + return rowLayout; + } + + private void renderNodes(LinearLayout parent, List nodes, int depth) { + if (nodes == null) { + return; + } + for (OfflineFormNode node : nodes) { + if (node == null) { + continue; + } + if (OfflineFormNode.TYPE_FIELD.equals(node.getNodeType()) && node.getField() != null) { + parent.addView(createFieldView(parent, node, depth), createChildLayoutParams()); + } else if (OfflineFormNode.TYPE_TEXT.equals(node.getNodeType())) { + parent.addView(createTextView(node, depth), createChildLayoutParams()); + } else { + parent.addView(createGroupView(node, depth), createChildLayoutParams()); + } + } + } + + private LinearLayout.LayoutParams createChildLayoutParams() { + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT); + params.setMargins(0, dp(itemView.getContext(), 6), 0, 0); + return params; + } + + private View createFieldView(ViewGroup parent, OfflineFormNode node, int depth) { + BaseFormItem formItem = node.getField(); + OfflineFormItemHandler handler = OfflineFormItemRegistry.getHandler(formItem.getItemType()); + BaseViewHolder holder = handler.createEditViewHolder(parent); + holder.itemView.setPadding(dp(itemView.getContext(), depth * 12), dp(itemView.getContext(), 4), 0, dp(itemView.getContext(), 4)); + + String originalTitle = formItem.getTitle(); + if (StringUtils.isNullOrBlank(originalTitle) && !StringUtils.isNullOrBlank(node.getTitle())) { + formItem.setTitle(node.getTitle()); + } + holder.bind(formItem, -1); + formItem.setTitle(originalTitle); + return holder.itemView; + } + + private View createGroupView(OfflineFormNode node, int depth) { + LinearLayout groupLayout = new LinearLayout(itemView.getContext()); + groupLayout.setOrientation(LinearLayout.VERTICAL); + groupLayout.setPadding(dp(itemView.getContext(), depth * 12), dp(itemView.getContext(), 4), 0, 0); + + TextView groupTitle = new TextView(itemView.getContext()); + groupTitle.setText(node.getTitle()); + groupTitle.setTextSize(13); + groupTitle.setTypeface(null, Typeface.BOLD); + groupTitle.setTextColor(itemView.getContext().getColor(R.color.huozige_blue)); + groupLayout.addView(groupTitle, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT)); + renderNodes(groupLayout, node.getChildren(), depth + 1); + return groupLayout; + } + + private View createTextView(OfflineFormNode node, int depth) { + LinearLayout textLayout = new LinearLayout(itemView.getContext()); + textLayout.setOrientation(LinearLayout.VERTICAL); + textLayout.setPadding(dp(itemView.getContext(), depth * 12), dp(itemView.getContext(), 4), 0, 0); + + TextView title = new TextView(itemView.getContext()); + title.setText(node.getTitle()); + title.setTextSize(13); + title.setTypeface(null, Typeface.BOLD); + title.setTextColor(itemView.getContext().getColor(R.color.offline_form_text_title)); + TextView content = new TextView(itemView.getContext()); + content.setText(node.getContent()); + content.setTextSize(13); + content.setTextColor(itemView.getContext().getColor(R.color.offline_form_text_content)); + content.setPadding(0, dp(itemView.getContext(), 2), 0, 0); + + textLayout.addView(title, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT)); + textLayout.addView(content, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT)); + return textLayout; + } + + private GradientDrawable createRowBackground() { + GradientDrawable drawable = new GradientDrawable(); + drawable.setColor(itemView.getContext().getColor(R.color.offline_form_text_bg)); + drawable.setCornerRadius(dp(itemView.getContext(), 6)); + drawable.setStroke(dp(itemView.getContext(), 1), itemView.getContext().getColor(R.color.offline_form_group_bg)); + return drawable; + } + + private String resolveAddButtonText() { + if (listItem != null && !StringUtils.isNullOrBlank(listItem.getAddButtonText())) { + return listItem.getAddButtonText(); + } + return itemView.getContext().getString(R.string.offline_button_add_list_item); + } + + private String resolveRowTitle(int index) { + if (listItem != null && !StringUtils.isNullOrBlank(listItem.getItemTitle())) { + return listItem.getItemTitle() + " " + (index + 1); + } + return itemView.getContext().getString(R.string.offline_text_list_item_title, index + 1); + } +} diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/formitem/password/PasswordFormItemHandler.java b/app/src/main/java/com/huozige/lab/container/offlineform/formitem/password/PasswordFormItemHandler.java new file mode 100644 index 00000000..ce653237 --- /dev/null +++ b/app/src/main/java/com/huozige/lab/container/offlineform/formitem/password/PasswordFormItemHandler.java @@ -0,0 +1,45 @@ +package com.huozige.lab.container.offlineform.formitem.password; + +import android.content.Context; +import android.text.InputType; +import android.view.View; + +import com.huozige.lab.container.offlineform.formitem.OfflineFormItemType; +import com.huozige.lab.container.offlineform.formitem.OfflineFormItemViewType; +import com.huozige.lab.container.offlineform.formitem.text.TextFormItemHandler; +import com.huozige.lab.container.offlineform.model.formitem.common.BaseFormItem; +import com.huozige.lab.container.offlineform.model.formitem.common.FormItemInput; +import com.huozige.lab.container.offlineform.model.formitem.text.TextFormItem; + +public class PasswordFormItemHandler extends TextFormItemHandler { + private static final String PASSWORD_MASK_CHAR = "\u2022"; + + @Override + public String getType() { + return OfflineFormItemType.PASSWORD.getValue(); + } + + @Override + public int getViewType() { + return OfflineFormItemViewType.PASSWORD.getValue(); + } + + @Override + public BaseFormItem fromInput(FormItemInput input) { + TextFormItem item = (TextFormItem) super.fromInput(input); + applyPasswordInputType(item); + return item; + } + + private void applyPasswordInputType(TextFormItem item) { + item.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD); + } + + @Override + public View createReadOnlyView(Context context, BaseFormItem item, String rawValue, boolean compact) { + if (rawValue == null || rawValue.isEmpty()) { + return super.createReadOnlyView(context, item, "", compact); + } + return super.createReadOnlyView(context, item, PASSWORD_MASK_CHAR.repeat(rawValue.length()), compact); + } +} diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/formitem/picker/DatePickerFormItemHandler.java b/app/src/main/java/com/huozige/lab/container/offlineform/formitem/picker/DatePickerFormItemHandler.java new file mode 100644 index 00000000..a85bce42 --- /dev/null +++ b/app/src/main/java/com/huozige/lab/container/offlineform/formitem/picker/DatePickerFormItemHandler.java @@ -0,0 +1,21 @@ +package com.huozige.lab.container.offlineform.formitem.picker; + +import com.huozige.lab.container.offlineform.formitem.OfflineFormItemType; +import com.huozige.lab.container.offlineform.formitem.OfflineFormItemViewType; + +public class DatePickerFormItemHandler extends PickerFormItemHandler { + @Override + public String getType() { + return OfflineFormItemType.DATE_PICKER.getValue(); + } + + @Override + public int getViewType() { + return OfflineFormItemViewType.DATE_PICKER.getValue(); + } + + @Override + protected PickerMode getPickerMode() { + return PickerMode.DATE; + } +} diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/formitem/picker/PickerFormItemHandler.java b/app/src/main/java/com/huozige/lab/container/offlineform/formitem/picker/PickerFormItemHandler.java new file mode 100644 index 00000000..9f57c69d --- /dev/null +++ b/app/src/main/java/com/huozige/lab/container/offlineform/formitem/picker/PickerFormItemHandler.java @@ -0,0 +1,61 @@ +package com.huozige.lab.container.offlineform.formitem.picker; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import com.alibaba.fastjson.JSONObject; +import com.huozige.lab.container.R; +import com.huozige.lab.container.offlineform.formitem.OfflineFormItemHandler; +import com.huozige.lab.container.offlineform.formitem.OfflineFormItemJsonHelper; +import com.huozige.lab.container.offlineform.formitem.OfflineFormItemJsonKeys; +import com.huozige.lab.container.offlineform.formitem.ReadOnlyFormItemViews; +import com.huozige.lab.container.offlineform.model.formitem.common.BaseFormItem; +import com.huozige.lab.container.offlineform.model.formitem.common.FormItemInput; +import com.huozige.lab.container.offlineform.model.formitem.picker.PickerFormItem; +import com.huozige.lab.container.offlineform.model.formitem.picker.PickerFormItemOptions; +import com.huozige.lab.container.proxy.support.offlinecustomform.viewholder.BaseViewHolder; + +public abstract class PickerFormItemHandler implements OfflineFormItemHandler { + @Override + public BaseFormItem fromInput(FormItemInput input) { + PickerFormItem item = new PickerFormItem(getType(), input.itemId, input.title, input.hint, input.required); + if (input.value != null) { + item.setValue(input.value); + } + PickerFormItemOptions options = (PickerFormItemOptions) input.options; + item.setIncludeSeconds(options != null && options.isIncludeSeconds()); + return item; + } + + @Override + public Class getOptionsClass() { + return PickerFormItemOptions.class; + } + + @Override + public JSONObject toJson(BaseFormItem item) { + PickerFormItem pickerItem = (PickerFormItem) item; + JSONObject jsonObject = OfflineFormItemJsonHelper.buildBaseOutput(pickerItem); + if (pickerItem.isIncludeSeconds()) { + PickerFormItemOptions options = new PickerFormItemOptions(); + options.setIncludeSeconds(true); + jsonObject.put(OfflineFormItemJsonKeys.FIELD_OPTIONS, options); + } + return jsonObject; + } + + @Override + public BaseViewHolder createEditViewHolder(ViewGroup parent) { + View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.custom_form_item_form_picker, parent, false); + return new PickerViewHolder(view, getPickerMode()); + } + + @Override + public View createReadOnlyView(Context context, BaseFormItem item, String rawValue, boolean compact) { + return ReadOnlyFormItemViews.createCompactValueView(context, rawValue); + } + + protected abstract PickerMode getPickerMode(); +} diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/formitem/picker/PickerMode.java b/app/src/main/java/com/huozige/lab/container/offlineform/formitem/picker/PickerMode.java new file mode 100644 index 00000000..78320863 --- /dev/null +++ b/app/src/main/java/com/huozige/lab/container/offlineform/formitem/picker/PickerMode.java @@ -0,0 +1,6 @@ +package com.huozige.lab.container.offlineform.formitem.picker; + +enum PickerMode { + DATE, + TIME +} diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/formitem/picker/PickerViewHolder.java b/app/src/main/java/com/huozige/lab/container/offlineform/formitem/picker/PickerViewHolder.java new file mode 100644 index 00000000..5e9c9c7a --- /dev/null +++ b/app/src/main/java/com/huozige/lab/container/offlineform/formitem/picker/PickerViewHolder.java @@ -0,0 +1,164 @@ +package com.huozige.lab.container.offlineform.formitem.picker; + +import android.app.DatePickerDialog; +import android.app.TimePickerDialog; +import android.view.Gravity; +import android.view.View; +import android.widget.LinearLayout; +import android.widget.NumberPicker; +import android.widget.TextView; + +import androidx.appcompat.app.AlertDialog; + +import com.huozige.lab.container.R; +import com.huozige.lab.container.offlineform.model.formitem.common.BaseFormItem; +import com.huozige.lab.container.offlineform.model.formitem.picker.PickerFormItem; +import com.huozige.lab.container.proxy.support.offlinecustomform.viewholder.BaseViewHolder; + +import java.util.Calendar; +import java.util.Locale; + +public class PickerViewHolder extends BaseViewHolder { + private final PickerMode pickerMode; + private final TextView tvTitle; + private final TextView tvValue; + private final TextView tvError; + private final TextView tvRequired; + + private PickerFormItem pickerItem; + + public PickerViewHolder(View itemView, PickerMode pickerMode) { + super(itemView); + this.pickerMode = pickerMode; + tvTitle = itemView.findViewById(R.id.tv_title); + tvValue = itemView.findViewById(R.id.tv_value); + tvError = itemView.findViewById(R.id.tv_error); + tvRequired = itemView.findViewById(R.id.tv_required); + setupListeners(); + } + + private void setupListeners() { + View clickableView = itemView.findViewById(R.id.picker_container); + clickableView.setOnClickListener(v -> showPicker()); + tvValue.setOnClickListener(v -> showPicker()); + } + + private void showPicker() { + if (pickerItem == null) { + return; + } + if (pickerMode == PickerMode.DATE) { + showDatePicker(); + } else { + showTimePicker(); + } + } + + private void showDatePicker() { + Calendar calendar = Calendar.getInstance(); + + new DatePickerDialog(itemView.getContext(), (view, year, month, dayOfMonth) -> { + pickerItem.setValue(String.format(Locale.CHINA, "%04d/%d/%d", year, month + 1, dayOfMonth)); + pickerItem.clearError(); + bindValue(); + updateErrorState(); + }, calendar.get(Calendar.YEAR), calendar.get(Calendar.MONTH), calendar.get(Calendar.DAY_OF_MONTH)).show(); + } + + private void showTimePicker() { + Calendar calendar = Calendar.getInstance(); + int second = calendar.get(Calendar.SECOND); + + if (!pickerItem.isIncludeSeconds()) { + new TimePickerDialog(itemView.getContext(), (view, hourOfDay, minute) -> { + pickerItem.setValue(String.format(Locale.CHINA, "%02d:%02d", hourOfDay, minute)); + pickerItem.clearError(); + bindValue(); + updateErrorState(); + }, calendar.get(Calendar.HOUR_OF_DAY), calendar.get(Calendar.MINUTE), true).show(); + return; + } + + NumberPicker hourPicker = createTimeNumberPicker(0, 23, calendar.get(Calendar.HOUR_OF_DAY)); + NumberPicker minutePicker = createTimeNumberPicker(0, 59, calendar.get(Calendar.MINUTE)); + NumberPicker secondPicker = createTimeNumberPicker(0, 59, second); + + LinearLayout contentLayout = new LinearLayout(itemView.getContext()); + contentLayout.setOrientation(LinearLayout.HORIZONTAL); + contentLayout.setGravity(Gravity.CENTER); + contentLayout.setPadding(0, 24, 0, 8); + contentLayout.addView(createTimePickerColumn(hourPicker, itemView.getContext().getString(R.string.offline_text_time_hour))); + contentLayout.addView(createTimePickerColumn(minutePicker, itemView.getContext().getString(R.string.offline_text_time_minute))); + contentLayout.addView(createTimePickerColumn(secondPicker, itemView.getContext().getString(R.string.offline_text_time_second))); + + new AlertDialog.Builder(itemView.getContext()) + .setView(contentLayout) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(android.R.string.ok, (dialog, which) -> { + pickerItem.setValue(String.format(Locale.CHINA, "%02d:%02d:%02d", hourPicker.getValue(), minutePicker.getValue(), secondPicker.getValue())); + pickerItem.clearError(); + bindValue(); + updateErrorState(); + }) + .show(); + } + + private NumberPicker createTimeNumberPicker(int minValue, int maxValue, int value) { + NumberPicker numberPicker = new NumberPicker(itemView.getContext()); + numberPicker.setMinValue(minValue); + numberPicker.setMaxValue(maxValue); + numberPicker.setValue(value); + numberPicker.setFormatter(value1 -> String.format(Locale.CHINA, "%02d", value1)); + return numberPicker; + } + + private LinearLayout createTimePickerColumn(NumberPicker numberPicker, String label) { + LinearLayout columnLayout = new LinearLayout(itemView.getContext()); + columnLayout.setOrientation(LinearLayout.VERTICAL); + columnLayout.setGravity(Gravity.CENTER); + + TextView labelView = new TextView(itemView.getContext()); + labelView.setText(label); + labelView.setGravity(Gravity.CENTER); + columnLayout.addView(numberPicker, new LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT)); + columnLayout.addView(labelView, new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT)); + return columnLayout; + } + + @Override + public void bind(BaseFormItem item) { + bind(item, getAdapterPosition()); + } + + @Override + public void bind(BaseFormItem item, int position) { + pickerItem = (PickerFormItem) item; + tvTitle.setText(pickerItem.getTitle()); + tvRequired.setVisibility(pickerItem.isRequired() ? View.VISIBLE : View.GONE); + bindValue(); + updateErrorState(); + } + + private void bindValue() { + String value = pickerItem.getValue(); + if (value != null && !value.isEmpty()) { + tvValue.setText(value); + tvValue.setHint(""); + } else { + tvValue.setText(""); + tvValue.setHint(pickerItem.getHint()); + } + } + + @Override + public void updateErrorState() { + if (pickerItem != null && pickerItem.getErrorMessage() != null) { + tvError.setVisibility(View.VISIBLE); + tvError.setText(pickerItem.getErrorMessage()); + tvValue.setBackgroundResource(R.drawable.custom_form_bg_edittext_error); + } else { + tvError.setVisibility(View.GONE); + tvValue.setBackgroundResource(R.drawable.custom_form_bg_edittext_normal); + } + } +} diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/formitem/picker/TimePickerFormItemHandler.java b/app/src/main/java/com/huozige/lab/container/offlineform/formitem/picker/TimePickerFormItemHandler.java new file mode 100644 index 00000000..8d8757d8 --- /dev/null +++ b/app/src/main/java/com/huozige/lab/container/offlineform/formitem/picker/TimePickerFormItemHandler.java @@ -0,0 +1,21 @@ +package com.huozige.lab.container.offlineform.formitem.picker; + +import com.huozige.lab.container.offlineform.formitem.OfflineFormItemType; +import com.huozige.lab.container.offlineform.formitem.OfflineFormItemViewType; + +public class TimePickerFormItemHandler extends PickerFormItemHandler { + @Override + public String getType() { + return OfflineFormItemType.TIME_PICKER.getValue(); + } + + @Override + public int getViewType() { + return OfflineFormItemViewType.TIME_PICKER.getValue(); + } + + @Override + protected PickerMode getPickerMode() { + return PickerMode.TIME; + } +} diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/formitem/select/SelectFormItemHandler.java b/app/src/main/java/com/huozige/lab/container/offlineform/formitem/select/SelectFormItemHandler.java new file mode 100644 index 00000000..48e6f6e9 --- /dev/null +++ b/app/src/main/java/com/huozige/lab/container/offlineform/formitem/select/SelectFormItemHandler.java @@ -0,0 +1,95 @@ +package com.huozige.lab.container.offlineform.formitem.select; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import com.alibaba.fastjson.JSONObject; +import com.huozige.lab.container.R; +import com.huozige.lab.container.offlineform.formitem.OfflineFormItemHandler; +import com.huozige.lab.container.offlineform.formitem.OfflineFormItemJsonHelper; +import com.huozige.lab.container.offlineform.formitem.OfflineFormItemJsonKeys; +import com.huozige.lab.container.offlineform.formitem.OfflineFormItemType; +import com.huozige.lab.container.offlineform.formitem.OfflineFormItemViewType; +import com.huozige.lab.container.offlineform.formitem.ReadOnlyFormItemViews; +import com.huozige.lab.container.offlineform.model.formitem.common.BaseFormItem; +import com.huozige.lab.container.offlineform.model.formitem.common.FormItemInput; +import com.huozige.lab.container.offlineform.model.formitem.select.SelectFormItem; +import com.huozige.lab.container.offlineform.model.formitem.select.SelectFormItemOptions; +import com.huozige.lab.container.offlineform.model.formitem.select.SelectOptionsInput; +import com.huozige.lab.container.proxy.support.offlinecustomform.viewholder.BaseViewHolder; + +public class SelectFormItemHandler implements OfflineFormItemHandler { + @Override + public String getType() { + return OfflineFormItemType.SELECT.getValue(); + } + + @Override + public int getViewType() { + return OfflineFormItemViewType.SELECT.getValue(); + } + + @Override + public BaseFormItem fromInput(FormItemInput input) { + SelectFormItem item = new SelectFormItem(getType(), input.itemId, input.title, input.hint, input.required); + SelectFormItemOptions options = (SelectFormItemOptions) input.options; + if (options != null && options.getSelectOptions() != null) { + for (SelectOptionsInput option : options.getSelectOptions()) { + item.addOption(option.value, option.label); + } + } + if (input.value != null) { + item.setSelectedValue(input.value); + } + return item; + } + + @Override + public Class getOptionsClass() { + return SelectFormItemOptions.class; + } + + @Override + public JSONObject toJson(BaseFormItem item) { + SelectFormItem selectItem = (SelectFormItem) item; + JSONObject jsonObject = OfflineFormItemJsonHelper.buildBaseOutput(selectItem); + jsonObject.put(OfflineFormItemJsonKeys.FIELD_OPTIONS, buildOptions(selectItem)); + return jsonObject; + } + + @Override + public BaseViewHolder createEditViewHolder(ViewGroup parent) { + View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.custom_form_item_form_select, parent, false); + return new SelectViewHolder(view); + } + + @Override + public View createReadOnlyView(Context context, BaseFormItem item, String rawValue, boolean compact) { + return ReadOnlyFormItemViews.createCompactValueView(context, getDisplayText((SelectFormItem) item, rawValue)); + } + + private String getDisplayText(SelectFormItem selectItem, String rawValue) { + if (rawValue == null || rawValue.isEmpty()) { + return ""; + } + for (SelectFormItem.Option option : selectItem.getOptions()) { + if (rawValue.equals(option.getValue())) { + return option.getDisplayText(); + } + } + return rawValue; + } + + private JSONObject buildOptions(SelectFormItem item) { + SelectFormItemOptions options = new SelectFormItemOptions(); + for (SelectFormItem.Option option : item.getOptions()) { + SelectOptionsInput optionInput = new SelectOptionsInput(); + optionInput.value = option.getValue(); + optionInput.label = option.getDisplayText(); + options.getSelectOptions().add(optionInput); + } + return (JSONObject) JSONObject.toJSON(options); + } +} diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/formitem/select/SelectViewHolder.java b/app/src/main/java/com/huozige/lab/container/offlineform/formitem/select/SelectViewHolder.java new file mode 100644 index 00000000..b52003e7 --- /dev/null +++ b/app/src/main/java/com/huozige/lab/container/offlineform/formitem/select/SelectViewHolder.java @@ -0,0 +1,124 @@ +package com.huozige.lab.container.offlineform.formitem.select; + +import android.app.AlertDialog; +import android.view.View; +import android.widget.TextView; + +import com.huozige.lab.container.R; +import com.huozige.lab.container.offlineform.model.formitem.common.BaseFormItem; +import com.huozige.lab.container.offlineform.model.formitem.select.SelectFormItem; +import com.huozige.lab.container.proxy.support.offlinecustomform.viewholder.BaseViewHolder; + +import java.util.List; + +public class SelectViewHolder extends BaseViewHolder { + private TextView tvTitle; + private TextView tvValue; + private TextView tvError; + private TextView tvRequired; + + private SelectFormItem selectItem; + + public SelectViewHolder(View itemView) { + super(itemView); + tvTitle = itemView.findViewById(R.id.tv_title); + tvValue = itemView.findViewById(R.id.tv_value); + tvError = itemView.findViewById(R.id.tv_error); + tvRequired = itemView.findViewById(R.id.tv_required); + + setupListeners(); + } + + private void setupListeners() { + View clickableView = itemView.findViewById(R.id.select_container); + if (clickableView == null) { + clickableView = itemView; + } + + clickableView.setOnClickListener(v -> { + if (selectItem != null) { + showOptionsDialog(); + } + }); + + tvValue.setOnClickListener(v -> { + if (selectItem != null) { + showOptionsDialog(); + } + }); + } + + private void showOptionsDialog() { + if (selectItem == null) return; + + List options = selectItem.getOptions(); + if (options.isEmpty()) { + tvError.setText(R.string.offline_error_select_no_options); + tvError.setVisibility(View.VISIBLE); + return; + } + + String[] items = new String[options.size()]; + for (int i = 0; i < options.size(); i++) { + items[i] = options.get(i).getDisplayText(); + } + + int selectedIndex = -1; + String currentValue = selectItem.getValue(); + for (int i = 0; i < options.size(); i++) { + if (options.get(i).getValue().equals(currentValue)) { + selectedIndex = i; + break; + } + } + + new AlertDialog.Builder(itemView.getContext()) + .setTitle(itemView.getContext().getString(R.string.offline_dialog_select_title, selectItem.getTitle())) + .setSingleChoiceItems(items, selectedIndex, (dialog, which) -> { + SelectFormItem.Option selected = options.get(which); + selectItem.setSelectedValue(selected.getValue()); + tvValue.setText(selected.getDisplayText()); + tvValue.setHint(""); + selectItem.clearError(); + updateErrorState(); + dialog.dismiss(); + }) + .show(); + } + + @Override + public void bind(BaseFormItem item) { + this.selectItem = (SelectFormItem) item; + bind(this.selectItem, getAdapterPosition()); + } + + @Override + public void bind(BaseFormItem item, int position) { + this.selectItem = (SelectFormItem) item; + + tvTitle.setText(selectItem.getTitle()); + tvRequired.setVisibility(selectItem.isRequired() ? View.VISIBLE : View.GONE); + + if (selectItem.getValue() != null && !selectItem.getValue().isEmpty()) { + tvValue.setText(selectItem.getSelectedDisplayText()); + tvValue.setHint(""); + } else { + tvValue.setText(""); + tvValue.setHint(selectItem.getHint()); + } + + updateErrorState(); + } + + @Override + public void updateErrorState() { + if (selectItem != null && selectItem.getErrorMessage() != null) { + tvError.setVisibility(View.VISIBLE); + tvError.setText(selectItem.getErrorMessage()); + tvValue.setBackgroundResource(R.drawable.custom_form_bg_edittext_error); + } else { + tvError.setVisibility(View.GONE); + tvValue.setBackgroundResource(R.drawable.custom_form_bg_edittext_normal); + } + } +} diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/formitem/text/TextFormItemHandler.java b/app/src/main/java/com/huozige/lab/container/offlineform/formitem/text/TextFormItemHandler.java new file mode 100644 index 00000000..42595fd5 --- /dev/null +++ b/app/src/main/java/com/huozige/lab/container/offlineform/formitem/text/TextFormItemHandler.java @@ -0,0 +1,89 @@ +package com.huozige.lab.container.offlineform.formitem.text; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import com.alibaba.fastjson.JSONObject; +import com.huozige.lab.container.R; +import com.huozige.lab.container.offlineform.formitem.OfflineFormItemHandler; +import com.huozige.lab.container.offlineform.formitem.OfflineFormItemJsonHelper; +import com.huozige.lab.container.offlineform.formitem.OfflineFormItemJsonKeys; +import com.huozige.lab.container.offlineform.formitem.OfflineFormItemType; +import com.huozige.lab.container.offlineform.formitem.OfflineFormItemViewType; +import com.huozige.lab.container.offlineform.formitem.ReadOnlyFormItemViews; +import com.huozige.lab.container.offlineform.model.formitem.common.BaseFormItem; +import com.huozige.lab.container.offlineform.model.formitem.common.FormItemInput; +import com.huozige.lab.container.offlineform.model.formitem.text.TextFormItemOptions; +import com.huozige.lab.container.offlineform.model.formitem.text.TextFormItem; +import com.huozige.lab.container.proxy.support.offlinecustomform.viewholder.BaseViewHolder; + +public class TextFormItemHandler implements OfflineFormItemHandler { + @Override + public String getType() { + return OfflineFormItemType.TEXT.getValue(); + } + + @Override + public int getViewType() { + return OfflineFormItemViewType.TEXT.getValue(); + } + + @Override + public BaseFormItem fromInput(FormItemInput input) { + TextFormItem item = new TextFormItem(getType(), input.itemId, input.title, input.hint, input.required); + applyInputOptions(item, input); + return item; + } + + @Override + public Class getOptionsClass() { + return TextFormItemOptions.class; + } + + @Override + public JSONObject toJson(BaseFormItem item) { + TextFormItem textItem = (TextFormItem) item; + JSONObject jsonObject = OfflineFormItemJsonHelper.buildBaseOutput(textItem); + jsonObject.put(OfflineFormItemJsonKeys.FIELD_OPTIONS, buildTextOptions(textItem)); + return jsonObject; + } + + @Override + public BaseViewHolder createEditViewHolder(ViewGroup parent) { + View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.custom_form_item_form_text, parent, false); + return new TextViewHolder(view); + } + + @Override + public View createReadOnlyView(Context context, BaseFormItem item, String rawValue, boolean compact) { + return ReadOnlyFormItemViews.createCompactValueView(context, rawValue); + } + + protected void applyInputOptions(TextFormItem item, FormItemInput input) { + if (input.value != null) { + item.setValue(input.value); + } + TextFormItemOptions options = (TextFormItemOptions) input.options; + if (options == null) { + return; + } + if (options.getMinLength() > 0) { + item.setMinLength(options.getMinLength()); + } + if (options.getMaxLength() > 0) { + item.setMaxLength(options.getMaxLength()); + } + if (options.getRegexPattern() != null) { + item.setRegexPattern(options.getRegexPattern()); + } + } + protected JSONObject buildTextOptions(TextFormItem item) { + TextFormItemOptions options = new TextFormItemOptions(); + options.setMinLength(item.getMinLength()); + options.setMaxLength(item.getMaxLength()); + options.setRegexPattern(item.getRegexPattern()); + return (JSONObject) JSONObject.toJSON(options); + } +} diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/formitem/text/TextViewHolder.java b/app/src/main/java/com/huozige/lab/container/offlineform/formitem/text/TextViewHolder.java new file mode 100644 index 00000000..ce4e2086 --- /dev/null +++ b/app/src/main/java/com/huozige/lab/container/offlineform/formitem/text/TextViewHolder.java @@ -0,0 +1,83 @@ +package com.huozige.lab.container.offlineform.formitem.text; + +import android.text.Editable; +import android.text.TextWatcher; +import android.view.View; +import android.widget.EditText; +import android.widget.TextView; + +import com.huozige.lab.container.R; +import com.huozige.lab.container.offlineform.model.formitem.common.BaseFormItem; +import com.huozige.lab.container.offlineform.model.formitem.text.TextFormItem; +import com.huozige.lab.container.proxy.support.offlinecustomform.viewholder.BaseViewHolder; + +public class TextViewHolder extends BaseViewHolder { + private TextView tvTitle; + private EditText etInput; + private TextView tvError; + private TextView tvRequired; + + private TextFormItem item; + + public TextViewHolder(View itemView) { + super(itemView); + tvTitle = itemView.findViewById(R.id.tv_title); + etInput = itemView.findViewById(R.id.et_input); + tvError = itemView.findViewById(R.id.tv_error); + tvRequired = itemView.findViewById(R.id.tv_required); + + setupListeners(); + } + + private void setupListeners() { + etInput.addTextChangedListener(new TextWatcher() { + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) {} + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) {} + + @Override + public void afterTextChanged(Editable s) { + if (item != null) { + item.setValue(s.toString()); + updateErrorState(); + } + } + }); + } + + @Override + public void bind(BaseFormItem item) { + this.item = (TextFormItem) item; + bind(this.item, getAdapterPosition()); + } + + @Override + public void bind(BaseFormItem item, int position) { + TextFormItem textItem = (TextFormItem) item; + this.item = textItem; + + tvTitle.setText(textItem.getTitle()); + etInput.setHint(textItem.getHint()); + etInput.setText(textItem.getValue() != null ? textItem.getValue() : ""); + etInput.setInputType(textItem.getInputType()); + tvRequired.setVisibility(textItem.isRequired() ? View.VISIBLE : View.GONE); + + updateErrorState(); + } + + @Override + public void updateErrorState() { + if (item == null) return; + + if (item.getErrorMessage() != null) { + tvError.setVisibility(View.VISIBLE); + tvError.setText(item.getErrorMessage()); + etInput.setBackgroundResource(R.drawable.custom_form_bg_edittext_error); + } else { + tvError.setVisibility(View.GONE); + etInput.setBackgroundResource(R.drawable.custom_form_bg_edittext_normal); + } + } +} diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/model/OfflineComputedInfo.java b/app/src/main/java/com/huozige/lab/container/offlineform/model/OfflineComputedInfo.java new file mode 100644 index 00000000..420af017 --- /dev/null +++ b/app/src/main/java/com/huozige/lab/container/offlineform/model/OfflineComputedInfo.java @@ -0,0 +1,23 @@ +package com.huozige.lab.container.offlineform.model; + +import java.util.ArrayList; +import java.util.List; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class OfflineComputedInfo { + public static final int DEFAULT_RECORD_PAGE_SIZE = 10; + + // HAC 本地为表单定义计算出的主题色,用于列表卡片边条和头像。 + private String theme = ""; + // 历史填报记录表格中展示的业务字段列,只保存表单项 fieldId,固定列不放在这里。 + private List displayColumns = new ArrayList<>(); + // 历史填报记录表每页显示的记录数。 + private int recordPageSize = DEFAULT_RECORD_PAGE_SIZE; + +} diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/model/OfflineFormDefinition.java b/app/src/main/java/com/huozige/lab/container/offlineform/model/OfflineFormDefinition.java new file mode 100644 index 00000000..aff28685 --- /dev/null +++ b/app/src/main/java/com/huozige/lab/container/offlineform/model/OfflineFormDefinition.java @@ -0,0 +1,25 @@ +package com.huozige.lab.container.offlineform.model; + +import java.util.ArrayList; +import java.util.List; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class OfflineFormDefinition { + // 表单定义的业务唯一标识,也是本地文件夹名称。 + private String patternId = ""; + // 表单定义版本号,由插件传入,填报记录保存时会写入记录文件。 + private String schemaVersion = ""; + // 表单定义名称,用于历史填报列表主标题展示。 + private String title = ""; + // 表单定义备注,用于历史填报列表描述展示。 + private String description = ""; + // 表单步骤定义,原生填报页根据步骤中的节点树渲染控件。 + private List steps = new ArrayList<>(); + +} diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/model/OfflineFormDefinitionFactory.java b/app/src/main/java/com/huozige/lab/container/offlineform/model/OfflineFormDefinitionFactory.java new file mode 100644 index 00000000..c3a89735 --- /dev/null +++ b/app/src/main/java/com/huozige/lab/container/offlineform/model/OfflineFormDefinitionFactory.java @@ -0,0 +1,35 @@ +package com.huozige.lab.container.offlineform.model; + +import java.util.ArrayList; +import java.util.List; + +/*“把插件传进来的 DTO 转成 APP 内部表单定义模型”的工厂类。*/ + +public class OfflineFormDefinitionFactory { + public static OfflineFormDefinition fromPatternInput(PatternInput input) { + return new OfflineFormDefinition( + input.patternId, + input.schemaVersion, + input.title, + input.description, + buildSteps(input.steps) + ); + } + + private static List buildSteps(List steps) { + List result = new ArrayList<>(); + if (steps == null) { + return result; + } + + for (PatternStepInput stepInput : steps) { + OfflineFormStep step = new OfflineFormStep(); + step.setStepId(stepInput.stepId); + step.setTitle(stepInput.title); + step.setItems(OfflineFormNodeFactory.buildNodes(stepInput.items)); + result.add(step); + } + + return result; + } +} diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/model/OfflineFormDefinitionFile.java b/app/src/main/java/com/huozige/lab/container/offlineform/model/OfflineFormDefinitionFile.java new file mode 100644 index 00000000..848a6805 --- /dev/null +++ b/app/src/main/java/com/huozige/lab/container/offlineform/model/OfflineFormDefinitionFile.java @@ -0,0 +1,16 @@ +package com.huozige.lab.container.offlineform.model; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class OfflineFormDefinitionFile { + // 插件下发的表单定义内容。 + private OfflineFormDefinition jsonSchema; + // HAC 本地计算出的展示信息。 + private OfflineComputedInfo computed = new OfflineComputedInfo(); + +} diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/model/OfflineFormDefinitionFlattener.java b/app/src/main/java/com/huozige/lab/container/offlineform/model/OfflineFormDefinitionFlattener.java new file mode 100644 index 00000000..d1ed773c --- /dev/null +++ b/app/src/main/java/com/huozige/lab/container/offlineform/model/OfflineFormDefinitionFlattener.java @@ -0,0 +1,94 @@ +package com.huozige.lab.container.offlineform.model; + +import com.huozige.lab.container.offlineform.model.formitem.common.BaseFormItem; + +import java.util.ArrayList; +import java.util.List; + +public final class OfflineFormDefinitionFlattener { + private OfflineFormDefinitionFlattener() { + } + + public static List flattenStep(OfflineFormStep step) { + List result = new ArrayList<>(); + if (step == null) { + return result; + } + int[] nextDisplayId = new int[]{0}; + flattenNodes(step.getItems(), 0, result, nextDisplayId); + return result; + } + + public static List flattenFields(OfflineFormDefinition definition) { + List result = new ArrayList<>(); + if (definition == null || definition.getSteps() == null) { + return result; + } + for (OfflineFormStep step : definition.getSteps()) { + collectFields(step == null ? null : step.getItems(), result); + } + return result; + } + + public static List flattenFields(List nodes) { + List result = new ArrayList<>(); + collectFields(nodes, result); + return result; + } + + public static List flattenFieldDisplayItems(OfflineFormDefinition definition) { + List result = new ArrayList<>(); + if (definition == null || definition.getSteps() == null) { + return result; + } + for (OfflineFormStep step : definition.getSteps()) { + for (OfflineFormDisplayItem displayItem : flattenStep(step)) { + if (displayItem.isField()) { + result.add(displayItem); + } + } + } + return result; + } + + private static void flattenNodes(List nodes, int depth, List result, int[] nextDisplayId) { + if (nodes == null) { + return; + } + for (OfflineFormNode node : nodes) { + if (node == null) { + continue; + } + if (OfflineFormNode.TYPE_FIELD.equals(node.getNodeType())) { + if (node.getField() != null) { + result.add(OfflineFormDisplayItem.field(node, depth, nextDisplayId[0]++)); + } + continue; + } + if (OfflineFormNode.TYPE_TEXT.equals(node.getNodeType())) { + result.add(OfflineFormDisplayItem.text(node, depth, nextDisplayId[0]++)); + continue; + } + result.add(OfflineFormDisplayItem.group(node, depth, nextDisplayId[0]++)); + flattenNodes(node.getChildren(), depth + 1, result, nextDisplayId); + } + } + + private static void collectFields(List nodes, List result) { + if (nodes == null) { + return; + } + for (OfflineFormNode node : nodes) { + if (node == null) { + continue; + } + if (OfflineFormNode.TYPE_FIELD.equals(node.getNodeType())) { + if (node.getField() != null) { + result.add(node.getField()); + } + continue; + } + collectFields(node.getChildren(), result); + } + } +} diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/model/OfflineFormDefinitionIndexItem.java b/app/src/main/java/com/huozige/lab/container/offlineform/model/OfflineFormDefinitionIndexItem.java new file mode 100644 index 00000000..dacd1dfb --- /dev/null +++ b/app/src/main/java/com/huozige/lab/container/offlineform/model/OfflineFormDefinitionIndexItem.java @@ -0,0 +1,24 @@ +package com.huozige.lab.container.offlineform.model; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class OfflineFormDefinitionIndexItem { + // 表单定义名称,用于历史填报列表主标题展示。 + private String title = ""; + // 表单定义备注,用于历史填报列表描述展示。 + private String description = ""; + // 预留状态字段,后续区分草稿、已提交、已同步等状态。 + private String status = ""; + // 表单定义的业务唯一标识,也是本地文件夹名称。 + private String patternId = ""; + // 表单定义版本号,由插件传入,填报记录保存时会写入记录文件。 + private String schemaVersion = ""; + // HAC 本地计算出的展示信息。 + private OfflineComputedInfo computed = new OfflineComputedInfo(); + +} diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/model/OfflineFormDefinitionOrder.java b/app/src/main/java/com/huozige/lab/container/offlineform/model/OfflineFormDefinitionOrder.java new file mode 100644 index 00000000..e34e0d3e --- /dev/null +++ b/app/src/main/java/com/huozige/lab/container/offlineform/model/OfflineFormDefinitionOrder.java @@ -0,0 +1,12 @@ +package com.huozige.lab.container.offlineform.model; + +import java.util.ArrayList; +import java.util.List; + +import lombok.Data; + +@Data +public class OfflineFormDefinitionOrder { + // 离线表单配置的展示顺序,只保存业务 key,具体标题和版本号从 definition.json 读取。 + private List patternIds = new ArrayList<>(); +} diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/model/OfflineFormDisplayItem.java b/app/src/main/java/com/huozige/lab/container/offlineform/model/OfflineFormDisplayItem.java new file mode 100644 index 00000000..ff0625e1 --- /dev/null +++ b/app/src/main/java/com/huozige/lab/container/offlineform/model/OfflineFormDisplayItem.java @@ -0,0 +1,111 @@ +package com.huozige.lab.container.offlineform.model; + +import com.huozige.lab.container.offlineform.model.formitem.common.BaseFormItem; + +/** + * 表单渲染用的扁平节点。 + *

+ * definition 中的 step/items 是树形结构,RecyclerView 和当前 FormAdapter 渲染时需要按顺序遍历。 + * 这个类保存树节点拍平后的展示信息,同时保留层级深度,避免把渲染状态写回 definition 模型。 + */ +public class OfflineFormDisplayItem { + public static final int VIEW_TYPE_GROUP = 10001; + public static final int VIEW_TYPE_TEXT = 10002; + + // group/text 节点使用,保存标题、内容、children、defaultCollapsed 等结构信息。 + private final OfflineFormNode node; + // field 节点使用,保存具体表单项,后续交给对应表单项 handler 渲染。 + private final BaseFormItem formItem; + // 当前节点在树中的深度,用于控制缩进和判断父子层级。 + private final int depth; + // 当前 step 内的稳定展示编号,用于记录展开/收起状态。 + private final int displayId; + + private OfflineFormDisplayItem(OfflineFormNode node, BaseFormItem formItem, int depth, int displayId) { + this.node = node; + this.formItem = formItem; + this.depth = depth; + this.displayId = displayId; + } + + /** + * 创建 group 展示节点。 + */ + public static OfflineFormDisplayItem group(OfflineFormNode node, int depth, int displayId) { + return new OfflineFormDisplayItem(node, null, depth, displayId); + } + + /** + * 创建普通说明文本展示节点。 + */ + public static OfflineFormDisplayItem text(OfflineFormNode node, int depth, int displayId) { + return new OfflineFormDisplayItem(node, null, depth, displayId); + } + + /** + * 创建可填报字段展示节点。 + */ + public static OfflineFormDisplayItem field(OfflineFormNode node, int depth, int displayId) { + return new OfflineFormDisplayItem(node, node.getField(), depth, displayId); + } + + /** + * 判断当前展示节点是否为 group。 + */ + public boolean isGroup() { + return node != null && OfflineFormNode.TYPE_GROUP.equals(node.getNodeType()); + } + + /** + * 判断当前展示节点是否为普通说明文本。 + */ + public boolean isText() { + return node != null && OfflineFormNode.TYPE_TEXT.equals(node.getNodeType()); + } + + /** + * 判断当前展示节点是否为可填报字段。 + */ + public boolean isField() { + return formItem != null; + } + + public String getFieldDisplayTitle() { + if (!isField()) { + return ""; + } + String title = formItem.getTitle(); + if (title != null && !title.isEmpty()) { + return title; + } + return node == null ? "" : node.getTitle(); + } + + /** + * 获取 group/text 对应的原始节点。 + */ + public OfflineFormNode getNode() { + return node; + } + + /** + * 获取 field 对应的表单项模型。 + */ + public BaseFormItem getFormItem() { + return formItem; + } + + /** + * 获取当前展示节点的层级深度。 + */ + public int getDepth() { + return depth; + } + + /** + * 获取当前展示节点在当前 step 内的展示编号。 + */ + public int getDisplayId() { + return displayId; + } +} diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/model/OfflineFormNode.java b/app/src/main/java/com/huozige/lab/container/offlineform/model/OfflineFormNode.java new file mode 100644 index 00000000..c3e9df63 --- /dev/null +++ b/app/src/main/java/com/huozige/lab/container/offlineform/model/OfflineFormNode.java @@ -0,0 +1,22 @@ +package com.huozige.lab.container.offlineform.model; + +import com.huozige.lab.container.offlineform.model.formitem.common.BaseFormItem; + +import java.util.ArrayList; +import java.util.List; + +import lombok.Data; + +@Data +public class OfflineFormNode { + public static final String TYPE_GROUP = "group"; + public static final String TYPE_TEXT = "text"; + public static final String TYPE_FIELD = "field"; + + private String nodeType = TYPE_GROUP; + private String title = ""; + private String content = ""; + private boolean defaultCollapsed; + private BaseFormItem field; + private List children = new ArrayList<>(); +} diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/model/OfflineFormNodeFactory.java b/app/src/main/java/com/huozige/lab/container/offlineform/model/OfflineFormNodeFactory.java new file mode 100644 index 00000000..706ce403 --- /dev/null +++ b/app/src/main/java/com/huozige/lab/container/offlineform/model/OfflineFormNodeFactory.java @@ -0,0 +1,80 @@ +package com.huozige.lab.container.offlineform.model; + +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONObject; +import com.huozige.lab.container.offlineform.formitem.OfflineFormItemJsonHelper; +import com.huozige.lab.container.offlineform.formitem.OfflineFormItemJsonKeys; +import com.huozige.lab.container.offlineform.formitem.OfflineFormItemRegistry; +import com.huozige.lab.container.offlineform.model.formitem.common.BaseFormItem; +import com.huozige.lab.container.offlineform.model.formitem.common.FormItemInput; + +import java.util.ArrayList; +import java.util.List; + +public final class OfflineFormNodeFactory { + private OfflineFormNodeFactory() { + } + + public static List buildNodes(List nodes) { + List result = new ArrayList<>(); + if (nodes == null) { + return result; + } + + for (PatternNodeInput nodeInput : nodes) { + if (nodeInput == null) { + continue; + } + OfflineFormNode node = new OfflineFormNode(); + node.setNodeType(nodeInput.nodeType); + node.setTitle(nodeInput.title); + node.setContent(nodeInput.content); + node.setDefaultCollapsed(nodeInput.defaultCollapsed); + if (OfflineFormNode.TYPE_FIELD.equals(nodeInput.nodeType) && nodeInput.field != null) { + node.setField(OfflineFormItemRegistry.fromInput(nodeInput.field)); + } else { + node.setChildren(buildNodes(nodeInput.children)); + } + result.add(node); + } + + return result; + } + + public static List cloneNodes(List nodes) { + List result = new ArrayList<>(); + if (nodes == null) { + return result; + } + for (OfflineFormNode node : nodes) { + if (node != null) { + result.add(cloneNode(node)); + } + } + return result; + } + + private static OfflineFormNode cloneNode(OfflineFormNode source) { + OfflineFormNode node = new OfflineFormNode(); + node.setNodeType(source.getNodeType()); + node.setTitle(source.getTitle()); + node.setContent(source.getContent()); + node.setDefaultCollapsed(source.isDefaultCollapsed()); + if (source.getField() != null) { + node.setField(cloneFormItem(source.getField())); + } + node.setChildren(cloneNodes(source.getChildren())); + return node; + } + + private static BaseFormItem cloneFormItem(BaseFormItem item) { + JSONObject jsonObject = OfflineFormItemRegistry.toJson(item); + FormItemInput input = OfflineFormItemJsonHelper.buildBaseInput(jsonObject); + Object options = jsonObject.get(OfflineFormItemJsonKeys.FIELD_OPTIONS); + if (options != null) { + Class optionsClass = OfflineFormItemRegistry.getOptionsClass(input.itemType); + input.options = optionsClass == null ? options : JSON.parseObject(JSON.toJSONString(options), optionsClass); + } + return OfflineFormItemRegistry.fromInput(input); + } +} diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/model/OfflineFormRecord.java b/app/src/main/java/com/huozige/lab/container/offlineform/model/OfflineFormRecord.java new file mode 100644 index 00000000..4d97d159 --- /dev/null +++ b/app/src/main/java/com/huozige/lab/container/offlineform/model/OfflineFormRecord.java @@ -0,0 +1,47 @@ +package com.huozige.lab.container.offlineform.model; + +import java.text.SimpleDateFormat; +import java.util.HashMap; +import java.util.Date; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class OfflineFormRecord { + // 单条填报记录唯一标识,也是记录文件名称。 + private String recordId = ""; + // 该记录所属表单定义的业务唯一标识。 + private String patternId = ""; + // 填报时使用的表单定义版本号。 + private String schemaVersion = ""; + // 当前记录状态,包含草稿、已提交、已导出。 + private OfflineFormRecordStatus status = OfflineFormRecordStatus.DRAFT; + // 记录创建时间戳。 + private long createdAt; + // 记录最后更新时间戳。 + private long updatedAt; + // 字段值集合,key 为表单项 id,value 为用户填写值。 + private Map values = new HashMap<>(); + + public static OfflineFormRecord createSubmitted(String patternId, String schemaVersion, Map values) { + long now = System.currentTimeMillis(); + return new OfflineFormRecord(createRecordId(), patternId, schemaVersion, OfflineFormRecordStatus.SUBMITTED, now, now, values); + } + + public static OfflineFormRecord createDraft(String patternId, String schemaVersion, Map values) { + long now = System.currentTimeMillis(); + return new OfflineFormRecord(createRecordId(), patternId, schemaVersion, OfflineFormRecordStatus.DRAFT, now, now, values); + } + + private static String createRecordId() { + String time = new SimpleDateFormat("yyyyMMdd_HHmmss_SSS", Locale.CHINA).format(new Date()); + return time + "_" + UUID.randomUUID().toString(); + } +} diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/model/OfflineFormRecordStatus.java b/app/src/main/java/com/huozige/lab/container/offlineform/model/OfflineFormRecordStatus.java new file mode 100644 index 00000000..62bc2288 --- /dev/null +++ b/app/src/main/java/com/huozige/lab/container/offlineform/model/OfflineFormRecordStatus.java @@ -0,0 +1,7 @@ +package com.huozige.lab.container.offlineform.model; + +public enum OfflineFormRecordStatus { + DRAFT, + SUBMITTED, + EXPORTED +} diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/model/OfflineFormStep.java b/app/src/main/java/com/huozige/lab/container/offlineform/model/OfflineFormStep.java new file mode 100644 index 00000000..ea1b0f86 --- /dev/null +++ b/app/src/main/java/com/huozige/lab/container/offlineform/model/OfflineFormStep.java @@ -0,0 +1,13 @@ +package com.huozige.lab.container.offlineform.model; + +import java.util.ArrayList; +import java.util.List; + +import lombok.Data; + +@Data +public class OfflineFormStep { + private String stepId = ""; + private String title = ""; + private List items = new ArrayList<>(); +} diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/model/PatternInput.java b/app/src/main/java/com/huozige/lab/container/offlineform/model/PatternInput.java new file mode 100644 index 00000000..00376ce5 --- /dev/null +++ b/app/src/main/java/com/huozige/lab/container/offlineform/model/PatternInput.java @@ -0,0 +1,19 @@ +package com.huozige.lab.container.offlineform.model; + +import java.util.ArrayList; +import java.util.List; + +import lombok.Data; + +@Data +public class PatternInput { + public String ticket = ""; + + public String patternId = ""; + public String schemaVersion = ""; + public String title = ""; + public String description = ""; + + public List steps = new ArrayList<>(); + public List displayColumns = new ArrayList<>(); +} diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/model/PatternNodeInput.java b/app/src/main/java/com/huozige/lab/container/offlineform/model/PatternNodeInput.java new file mode 100644 index 00000000..ea6d56b5 --- /dev/null +++ b/app/src/main/java/com/huozige/lab/container/offlineform/model/PatternNodeInput.java @@ -0,0 +1,18 @@ +package com.huozige.lab.container.offlineform.model; + +import com.huozige.lab.container.offlineform.model.formitem.common.FormItemInput; + +import java.util.ArrayList; +import java.util.List; + +import lombok.Data; + +@Data +public class PatternNodeInput { + public String nodeType = OfflineFormNode.TYPE_GROUP; + public String title = ""; + public String content = ""; + public boolean defaultCollapsed; + public FormItemInput field; + public List children = new ArrayList<>(); +} diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/model/PatternStepInput.java b/app/src/main/java/com/huozige/lab/container/offlineform/model/PatternStepInput.java new file mode 100644 index 00000000..cb887edb --- /dev/null +++ b/app/src/main/java/com/huozige/lab/container/offlineform/model/PatternStepInput.java @@ -0,0 +1,13 @@ +package com.huozige.lab.container.offlineform.model; + +import java.util.ArrayList; +import java.util.List; + +import lombok.Data; + +@Data +public class PatternStepInput { + public String stepId = ""; + public String title = ""; + public List items = new ArrayList<>(); +} diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/common/AttachmentFormItemValue.java b/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/common/AttachmentFormItemValue.java new file mode 100644 index 00000000..60adf6c4 --- /dev/null +++ b/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/common/AttachmentFormItemValue.java @@ -0,0 +1,11 @@ +package com.huozige.lab.container.offlineform.model.formitem.common; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class AttachmentFormItemValue { + private String originalName; + private String fileName; +} diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/common/BaseFormItem.java b/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/common/BaseFormItem.java new file mode 100644 index 00000000..a67717b0 --- /dev/null +++ b/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/common/BaseFormItem.java @@ -0,0 +1,28 @@ +package com.huozige.lab.container.offlineform.model.formitem.common; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public abstract class BaseFormItem { + private String itemType; + private String id; + private String title; + private String hint; + private boolean required; + private String errorMessage; + + public BaseFormItem(String itemType, String id, String title, String hint, boolean required) { + this.itemType = itemType; + this.id = id; + this.title = title; + this.hint = hint; + this.required = required; + } + + public abstract String getValue(); + public abstract boolean isEmpty(); + public abstract boolean validate(); + public abstract void clearError(); +} diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/common/FormItemInput.java b/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/common/FormItemInput.java new file mode 100644 index 00000000..47ad7484 --- /dev/null +++ b/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/common/FormItemInput.java @@ -0,0 +1,14 @@ +package com.huozige.lab.container.offlineform.model.formitem.common; + +import lombok.Data; + +@Data +public class FormItemInput { + public String itemId = ""; + public String title = ""; + public String hint = ""; + public boolean required; + public String itemType = ""; + public String value = ""; + public Object options; +} diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/file/FileFormItem.java b/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/file/FileFormItem.java new file mode 100644 index 00000000..247c3b78 --- /dev/null +++ b/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/file/FileFormItem.java @@ -0,0 +1,92 @@ +package com.huozige.lab.container.offlineform.model.formitem.file; + +import com.huozige.lab.container.offlineform.model.formitem.common.AttachmentFormItemValue; +import com.huozige.lab.container.offlineform.model.formitem.common.BaseFormItem; +import com.huozige.lab.container.offlineform.util.Utils; + +import java.util.ArrayList; +import java.util.List; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class FileFormItem extends BaseFormItem { + private FileItemConfig fileItemConfig = new FileItemConfig(); + private List files = new ArrayList<>(); + private String patternId = ""; + + public FileFormItem(String itemType, String id, String title, String hint, boolean required) { + super(itemType, id, title, hint, required); + } + + @Override + public String getValue() { + return Utils.serializeAttachmentValues(files); + } + + @Override + public boolean isEmpty() { + return files == null || files.isEmpty(); + } + + public void setValue(String value) { + files = parseAttachments(value); + } + + public void addFile(AttachmentFormItemValue file) { + if (file == null || file.getOriginalName() == null || file.getOriginalName().isEmpty() + || file.getFileName() == null || file.getFileName().isEmpty()) { + return; + } + if (files == null) { + files = new ArrayList<>(); + } + files.add(file); + } + + public void removeFile(String originalName) { + if (files != null) { + files.removeIf(file -> file != null && originalName.equals(file.getOriginalName())); + } + } + + public boolean containsOriginalName(String originalName) { + if (files == null) { + return false; + } + for (AttachmentFormItemValue file : files) { + if (file != null && originalName.equals(file.getOriginalName())) { + return true; + } + } + return false; + } + + @Override + public boolean validate() { + clearError(); + if (isRequired() && isEmpty()) { + setErrorMessage("请上传" + getTitle()); + return false; + } + int maxCount = fileItemConfig == null ? 0 : fileItemConfig.getMaxCount(); + if (maxCount > 0 && files != null && files.size() > maxCount) { + setErrorMessage("最多只能上传" + maxCount + "个文件"); + return false; + } + return true; + } + + @Override + public void clearError() { + setErrorMessage(null); + } + + public static List parseAttachments(String value) { + return Utils.parseAttachmentValues(value, attachment -> + attachment.getOriginalName() != null && !attachment.getOriginalName().isEmpty() + && attachment.getFileName() != null && !attachment.getFileName().isEmpty()); + } +} diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/file/FileFormItemOptions.java b/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/file/FileFormItemOptions.java new file mode 100644 index 00000000..6ff4bafa --- /dev/null +++ b/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/file/FileFormItemOptions.java @@ -0,0 +1,10 @@ +package com.huozige.lab.container.offlineform.model.formitem.file; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class FileFormItemOptions { + private FileItemConfig fileItemConfig; +} diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/file/FileItemConfig.java b/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/file/FileItemConfig.java new file mode 100644 index 00000000..02f70b82 --- /dev/null +++ b/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/file/FileItemConfig.java @@ -0,0 +1,12 @@ +package com.huozige.lab.container.offlineform.model.formitem.file; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class FileItemConfig { + private int maxCount; + private String allowedExtensions = ""; + private int maxFileSizeKb; +} diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/image/ImageCompressionOptions.java b/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/image/ImageCompressionOptions.java new file mode 100644 index 00000000..8deddb87 --- /dev/null +++ b/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/image/ImageCompressionOptions.java @@ -0,0 +1,19 @@ +package com.huozige.lab.container.offlineform.model.formitem.image; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class ImageCompressionOptions { + public static final int DEFAULT_MAX_LONG_EDGE = 1600; + public static final int DEFAULT_JPEG_QUALITY = 80; + public static final int DEFAULT_MAX_FILE_SIZE_KB = 0; + public static final int DEFAULT_MIN_QUALITY = 60; + + private boolean enableCompression; + private int maxLongEdge = DEFAULT_MAX_LONG_EDGE; + private int jpegQuality = DEFAULT_JPEG_QUALITY; + private int maxFileSizeKb = DEFAULT_MAX_FILE_SIZE_KB; + private int minQuality = DEFAULT_MIN_QUALITY; +} diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/image/ImageFormItem.java b/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/image/ImageFormItem.java new file mode 100644 index 00000000..2a6d05ac --- /dev/null +++ b/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/image/ImageFormItem.java @@ -0,0 +1,92 @@ +package com.huozige.lab.container.offlineform.model.formitem.image; + +import com.huozige.lab.container.offlineform.model.formitem.common.AttachmentFormItemValue; +import com.huozige.lab.container.offlineform.model.formitem.common.BaseFormItem; +import com.huozige.lab.container.offlineform.util.Utils; + +import java.util.ArrayList; +import java.util.List; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class ImageFormItem extends BaseFormItem { + public static final int DEFAULT_MAX_COUNT = 0; + + private int maxCount = DEFAULT_MAX_COUNT; + private boolean allowImageUpload; + private ImageCompressionOptions compression = new ImageCompressionOptions(); + private ImageWatermarkOptions watermark = new ImageWatermarkOptions(); + private List images = new ArrayList<>(); + private String patternId = ""; + + public ImageFormItem(String itemType, String id, String title, String hint, boolean required) { + super(itemType, id, title, hint, required); + } + + @Override + public String getValue() { + return Utils.serializeAttachmentValues(images); + } + + @Override + public boolean isEmpty() { + return images == null || images.isEmpty(); + } + + public void setValue(String value) { + images = parseImages(value); + } + + public void addImage(AttachmentFormItemValue image) { + if (images == null) { + images = new ArrayList<>(); + } + images.add(image); + } + + public void removeImage(AttachmentFormItemValue image) { + if (images != null) { + images.remove(image); + } + } + + public void setImagesFromFileNames(List fileNames) { + images = new ArrayList<>(); + if (fileNames == null) { + return; + } + for (String fileName : fileNames) { + if (fileName != null && !fileName.isEmpty()) { + AttachmentFormItemValue image = new AttachmentFormItemValue(); + image.setFileName(fileName); + images.add(image); + } + } + } + + @Override + public boolean validate() { + clearError(); + if (isRequired() && isEmpty()) { + setErrorMessage("请添加" + getTitle()); + return false; + } + if (maxCount > 0 && images != null && images.size() > maxCount) { + setErrorMessage("最多只能添加" + maxCount + "张图片"); + return false; + } + return true; + } + + @Override + public void clearError() { + setErrorMessage(null); + } + + public static List parseImages(String value) { + return Utils.parseAttachmentValues(value, image -> image.getFileName() != null && !image.getFileName().isEmpty()); + } +} diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/image/ImageFormItemOptions.java b/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/image/ImageFormItemOptions.java new file mode 100644 index 00000000..533372ad --- /dev/null +++ b/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/image/ImageFormItemOptions.java @@ -0,0 +1,13 @@ +package com.huozige.lab.container.offlineform.model.formitem.image; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class ImageFormItemOptions { + private int maxCount; + private boolean allowImageUpload; + private ImageCompressionOptions compression; + private ImageWatermarkOptions watermark; +} diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/image/ImageWatermarkField.java b/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/image/ImageWatermarkField.java new file mode 100644 index 00000000..f9edb1e4 --- /dev/null +++ b/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/image/ImageWatermarkField.java @@ -0,0 +1,11 @@ +package com.huozige.lab.container.offlineform.model.formitem.image; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class ImageWatermarkField { + private String key = ""; + private String value = ""; +} diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/image/ImageWatermarkOptions.java b/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/image/ImageWatermarkOptions.java new file mode 100644 index 00000000..dcac99b8 --- /dev/null +++ b/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/image/ImageWatermarkOptions.java @@ -0,0 +1,14 @@ +package com.huozige.lab.container.offlineform.model.formitem.image; + +import java.util.ArrayList; +import java.util.List; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class ImageWatermarkOptions { + private boolean enableTimestamp; + private List items = new ArrayList<>(); +} diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/list/ListFormItem.java b/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/list/ListFormItem.java new file mode 100644 index 00000000..585881a8 --- /dev/null +++ b/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/list/ListFormItem.java @@ -0,0 +1,187 @@ +package com.huozige.lab.container.offlineform.model.formitem.list; + +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONArray; +import com.alibaba.fastjson.JSONObject; +import com.huozige.lab.container.offlineform.formitem.OfflineFormItemValueHelper; +import com.huozige.lab.container.offlineform.model.OfflineFormDefinitionFlattener; +import com.huozige.lab.container.offlineform.model.OfflineFormNode; +import com.huozige.lab.container.offlineform.model.OfflineFormNodeFactory; +import com.huozige.lab.container.offlineform.model.PatternNodeInput; +import com.huozige.lab.container.offlineform.model.formitem.common.BaseFormItem; +import com.huozige.lab.container.utilities.StringUtils; + +import java.util.ArrayList; +import java.util.List; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class ListFormItem extends BaseFormItem { + private int minCount; + private int maxCount; + private int defaultCount; + private String addButtonText = ""; + private String itemTitle = ""; + private String patternId = ""; + private List templateInputs = new ArrayList<>(); + private List templateNodes = new ArrayList<>(); + private List rows = new ArrayList<>(); + + public ListFormItem(String itemType, String id, String title, String hint, boolean required) { + super(itemType, id, title, hint, required); + } + + @Override + public String getValue() { + JSONArray array = new JSONArray(); + for (ListFormItemRow row : rows) { + JSONObject rowValue = new JSONObject(); + for (BaseFormItem field : OfflineFormDefinitionFlattener.flattenFields(row.getNodes())) { + rowValue.put(field.getId(), field.getValue()); + } + array.add(rowValue); + } + return array.toJSONString(); + } + + @Override + public boolean isEmpty() { + return rows == null || rows.isEmpty(); + } + + public void setValue(String value) { + rows = new ArrayList<>(); + if (!StringUtils.isNullOrBlank(value)) { + JSONArray array = parseRows(value); + for (int i = 0; i < array.size(); i++) { + JSONObject rowValue = array.getJSONObject(i); + ListFormItemRow row = addRow(); + if (row != null && rowValue != null) { + applyRowValue(row, rowValue); + } + } + } + ensureDefaultRows(); + clearError(); + } + + public void ensureDefaultRows() { + int targetCount = Math.max(0, Math.max(defaultCount, minCount)); + while (rows.size() < targetCount && canAddRow()) { + rows.add(createRow()); + } + } + + public boolean canAddRow() { + return maxCount <= 0 || rows.size() < maxCount; + } + + public boolean canRemoveRow() { + return rows.size() > Math.max(0, minCount); + } + + public ListFormItemRow addRow() { + if (!canAddRow()) { + return null; + } + ListFormItemRow row = createRow(); + rows.add(row); + return row; + } + + public boolean removeRow(int index) { + if (canRemoveRow() && index >= 0 && index < rows.size()) { + rows.remove(index); + return true; + } + return false; + } + + @Override + public boolean validate() { + clearError(); + if (isRequired() && rows.isEmpty()) { + setErrorMessage("\u8bf7\u6dfb\u52a0" + getTitle()); + return false; + } + if (minCount > 0 && rows.size() < minCount) { + setErrorMessage(getTitle() + "\u81f3\u5c11\u9700\u8981" + minCount + "\u9879"); + return false; + } + if (maxCount > 0 && rows.size() > maxCount) { + setErrorMessage(getTitle() + "\u6700\u591a\u53ea\u80fd" + maxCount + "\u9879"); + return false; + } + + boolean valid = true; + for (ListFormItemRow row : rows) { + for (BaseFormItem field : OfflineFormDefinitionFlattener.flattenFields(row.getNodes())) { + if (!field.validate()) { + valid = false; + } + } + } + if (!valid) { + setErrorMessage("\u8bf7\u68c0\u67e5" + getTitle()); + } + return valid; + } + + @Override + public void clearError() { + setErrorMessage(null); + for (ListFormItemRow row : rows) { + for (BaseFormItem field : OfflineFormDefinitionFlattener.flattenFields(row.getNodes())) { + field.clearError(); + } + } + } + + public void setPatternId(String patternId) { + this.patternId = patternId == null ? "" : patternId; + for (ListFormItemRow row : rows) { + OfflineFormItemValueHelper.applyPatternId( + OfflineFormDefinitionFlattener.flattenFields(row.getNodes()), + this.patternId); + } + } + + public int getRowCount() { + return rows == null ? 0 : rows.size(); + } + + public static JSONArray parseRows(String value) { + if (StringUtils.isNullOrBlank(value)) { + return new JSONArray(); + } + try { + return JSON.parseArray(value); + } catch (RuntimeException e) { + return new JSONArray(); + } + } + + private ListFormItemRow createRow() { + List rowNodes = OfflineFormNodeFactory.cloneNodes(templateNodes); + OfflineFormItemValueHelper.applyPatternId(OfflineFormDefinitionFlattener.flattenFields(rowNodes), patternId); + return new ListFormItemRow(rowNodes); + } + + private void applyRowValue(ListFormItemRow row, JSONObject rowValue) { + for (BaseFormItem field : OfflineFormDefinitionFlattener.flattenFields(row.getNodes())) { + OfflineFormItemValueHelper.applyValue(field, rowValue.getString(field.getId())); + } + } + + @Getter + public static class ListFormItemRow { + private final List nodes; + + ListFormItemRow(List nodes) { + this.nodes = nodes == null ? new ArrayList<>() : nodes; + } + } +} diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/list/ListFormItemOptions.java b/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/list/ListFormItemOptions.java new file mode 100644 index 00000000..a110e604 --- /dev/null +++ b/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/list/ListFormItemOptions.java @@ -0,0 +1,26 @@ +package com.huozige.lab.container.offlineform.model.formitem.list; + +import com.huozige.lab.container.offlineform.model.PatternNodeInput; + +import java.util.ArrayList; +import java.util.List; + +import lombok.Data; + +@Data +public class ListFormItemOptions { + private int minCount; + private int maxCount; + private int defaultCount; + private String addButtonText = ""; + private String itemTitle = ""; + private List children = new ArrayList<>(); + private List items = new ArrayList<>(); + + public List resolveTemplateNodes() { + if (children != null && !children.isEmpty()) { + return children; + } + return items == null ? new ArrayList<>() : items; + } +} diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/picker/PickerFormItem.java b/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/picker/PickerFormItem.java new file mode 100644 index 00000000..33fd3dbe --- /dev/null +++ b/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/picker/PickerFormItem.java @@ -0,0 +1,43 @@ +package com.huozige.lab.container.offlineform.model.formitem.picker; + +import com.huozige.lab.container.offlineform.model.formitem.common.BaseFormItem; +import com.huozige.lab.container.utilities.StringUtils; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class PickerFormItem extends BaseFormItem { + private String value = ""; + private boolean includeSeconds; + + public PickerFormItem(String itemType, String id, String title, String hint, boolean required) { + super(itemType, id, title, hint, required); + } + + @Override + public String getValue() { + return value; + } + + @Override + public boolean isEmpty() { + return StringUtils.isNullOrBlank(value); + } + + @Override + public boolean validate() { + clearError(); + if (isRequired() && isEmpty()) { + setErrorMessage("请选择" + getTitle()); + return false; + } + return true; + } + + @Override + public void clearError() { + setErrorMessage(null); + } +} diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/picker/PickerFormItemOptions.java b/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/picker/PickerFormItemOptions.java new file mode 100644 index 00000000..8b2c3f90 --- /dev/null +++ b/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/picker/PickerFormItemOptions.java @@ -0,0 +1,10 @@ +package com.huozige.lab.container.offlineform.model.formitem.picker; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class PickerFormItemOptions { + private boolean includeSeconds; +} diff --git a/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/select/SelectFormItem.java b/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/select/SelectFormItem.java new file mode 100644 index 00000000..4f5de5b5 --- /dev/null +++ b/app/src/main/java/com/huozige/lab/container/offlineform/model/formitem/select/SelectFormItem.java @@ -0,0 +1,75 @@ +package com.huozige.lab.container.offlineform.model.formitem.select; + +import com.huozige.lab.container.offlineform.model.formitem.common.BaseFormItem; + +import java.util.ArrayList; +import java.util.List; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +public class SelectFormItem extends BaseFormItem { + @Getter + @AllArgsConstructor + public static class Option { + private String value; + private String displayText; + } + + private List