From fa81292bd82292da1efa5f5f88103bf1560f9bbb Mon Sep 17 00:00:00 2001 From: isra el Date: Sat, 31 May 2025 12:00:57 +0300 Subject: [PATCH] ui(android): major ui/ux improvement on the android app and add crashlytics --- android/app/build.gradle | 4 +- android/app/src/main/AndroidManifest.xml | 3 +- .../main/java/com/vernu/sms/TextBeeUtils.java | 54 ++ .../vernu/sms/activities/MainActivity.java | 171 ++++- .../res/color/radio_button_text_color.xml | 6 + .../src/main/res/color/radio_button_tint.xml | 6 + .../main/res/drawable/ic_baseline_edit_24.xml | 10 + .../main/res/drawable/ic_baseline_info_24.xml | 10 + .../app/src/main/res/layout/activity_main.xml | 680 +++++++++++++----- .../app/src/main/res/values-night/colors.xml | 17 + .../app/src/main/res/values-night/themes.xml | 29 +- android/app/src/main/res/values/colors.xml | 7 + android/app/src/main/res/values/styles.xml | 10 + android/app/src/main/res/values/themes.xml | 14 + android/build.gradle | 1 + 15 files changed, 818 insertions(+), 204 deletions(-) create mode 100644 android/app/src/main/res/color/radio_button_text_color.xml create mode 100644 android/app/src/main/res/color/radio_button_tint.xml create mode 100644 android/app/src/main/res/drawable/ic_baseline_edit_24.xml create mode 100644 android/app/src/main/res/drawable/ic_baseline_info_24.xml create mode 100644 android/app/src/main/res/values-night/colors.xml create mode 100644 android/app/src/main/res/values/styles.xml diff --git a/android/app/build.gradle b/android/app/build.gradle index 17449c6..781005e 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -1,6 +1,7 @@ plugins { id 'com.android.application' id 'com.google.gms.google-services' + id 'com.google.firebase.crashlytics' } android { @@ -37,7 +38,7 @@ android { dependencies { implementation 'androidx.appcompat:appcompat:1.3.0' - implementation 'com.google.android.material:material:1.4.0' + implementation 'com.google.android.material:material:1.8.0' implementation 'androidx.constraintlayout:constraintlayout:2.0.4' testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.1.3' @@ -47,6 +48,7 @@ dependencies { implementation 'com.google.firebase:firebase-analytics' implementation 'com.google.firebase:firebase-messaging' implementation 'com.google.firebase:firebase-messaging-directboot' + implementation 'com.google.firebase:firebase-crashlytics' implementation 'com.google.code.gson:gson:2.9.0' implementation 'com.squareup.retrofit2:retrofit:2.9.0' diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index a09b74d..a6033e3 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -55,7 +55,8 @@ + android:exported="true" + android:theme="@style/Theme.SMSGateway.NoActionBar"> diff --git a/android/app/src/main/java/com/vernu/sms/TextBeeUtils.java b/android/app/src/main/java/com/vernu/sms/TextBeeUtils.java index e30c4b1..db6264b 100644 --- a/android/app/src/main/java/com/vernu/sms/TextBeeUtils.java +++ b/android/app/src/main/java/com/vernu/sms/TextBeeUtils.java @@ -7,16 +7,21 @@ import android.content.pm.PackageManager; import android.os.Build; import android.telephony.SubscriptionInfo; import android.telephony.SubscriptionManager; +import android.util.Log; import androidx.core.app.ActivityCompat; import androidx.core.content.ContextCompat; +import com.google.firebase.crashlytics.FirebaseCrashlytics; import com.vernu.sms.services.StickyNotificationService; import java.util.ArrayList; import java.util.List; +import java.util.Map; public class TextBeeUtils { + private static final String TAG = "TextBeeUtils"; + public static boolean isPermissionGranted(Context context, String permission) { return ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED; } @@ -50,4 +55,53 @@ public class TextBeeUtils { Intent notificationIntent = new Intent(context, StickyNotificationService.class); context.stopService(notificationIntent); } + + /** + * Log a non-fatal exception to Crashlytics with additional context information + * + * @param throwable The exception to log + * @param message A message describing what happened + * @param customData Optional map of custom key-value pairs to add as context + */ + public static void logException(Throwable throwable, String message, Map customData) { + try { + Log.e(TAG, message, throwable); + + FirebaseCrashlytics crashlytics = FirebaseCrashlytics.getInstance(); + crashlytics.log(message); + + // Add any custom data as key-value pairs + if (customData != null) { + for (Map.Entry entry : customData.entrySet()) { + if (entry.getValue() instanceof String) { + crashlytics.setCustomKey(entry.getKey(), (String) entry.getValue()); + } else if (entry.getValue() instanceof Boolean) { + crashlytics.setCustomKey(entry.getKey(), (Boolean) entry.getValue()); + } else if (entry.getValue() instanceof Integer) { + crashlytics.setCustomKey(entry.getKey(), (Integer) entry.getValue()); + } else if (entry.getValue() instanceof Long) { + crashlytics.setCustomKey(entry.getKey(), (Long) entry.getValue()); + } else if (entry.getValue() instanceof Float) { + crashlytics.setCustomKey(entry.getKey(), (Float) entry.getValue()); + } else if (entry.getValue() instanceof Double) { + crashlytics.setCustomKey(entry.getKey(), (Double) entry.getValue()); + } else if (entry.getValue() != null) { + crashlytics.setCustomKey(entry.getKey(), entry.getValue().toString()); + } + } + } + + // Record the exception + crashlytics.recordException(throwable); + } catch (Exception e) { + Log.e(TAG, "Error logging exception to Crashlytics", e); + } + } + + /** + * Simplified method to log a non-fatal exception with just a message + */ + public static void logException(Throwable throwable, String message) { + logException(throwable, message, null); + } } diff --git a/android/app/src/main/java/com/vernu/sms/activities/MainActivity.java b/android/app/src/main/java/com/vernu/sms/activities/MainActivity.java index b9c2800..63c974f 100644 --- a/android/app/src/main/java/com/vernu/sms/activities/MainActivity.java +++ b/android/app/src/main/java/com/vernu/sms/activities/MainActivity.java @@ -31,6 +31,7 @@ import com.vernu.sms.R; import com.vernu.sms.dtos.RegisterDeviceInputDTO; import com.vernu.sms.dtos.RegisterDeviceResponseDTO; import com.vernu.sms.helpers.SharedPreferenceHelper; +import com.google.firebase.crashlytics.FirebaseCrashlytics; import java.util.Arrays; import java.util.Objects; import retrofit2.Call; @@ -41,10 +42,10 @@ public class MainActivity extends AppCompatActivity { private Context mContext; private Switch gatewaySwitch, receiveSMSSwitch; - private EditText apiKeyEditText, fcmTokenEditText; - private Button registerDeviceBtn, grantSMSPermissionBtn, scanQRBtn; + private EditText apiKeyEditText, fcmTokenEditText, deviceIdEditText; + private Button registerDeviceBtn, grantSMSPermissionBtn, scanQRBtn, checkUpdatesBtn; private ImageButton copyDeviceIdImgBtn; - private TextView deviceBrandAndModelTxt, deviceIdTxt; + private TextView deviceBrandAndModelTxt, deviceIdTxt, appVersionNameTxt, appVersionCodeTxt; private RadioGroup defaultSimSlotRadioGroup; private static final int SCAN_QR_REQUEST_CODE = 49374; private static final int PERMISSION_REQUEST_CODE = 0; @@ -62,6 +63,7 @@ public class MainActivity extends AppCompatActivity { receiveSMSSwitch = findViewById(R.id.receiveSMSSwitch); apiKeyEditText = findViewById(R.id.apiKeyEditText); fcmTokenEditText = findViewById(R.id.fcmTokenEditText); + deviceIdEditText = findViewById(R.id.deviceIdEditText); registerDeviceBtn = findViewById(R.id.registerDeviceBtn); grantSMSPermissionBtn = findViewById(R.id.grantSMSPermissionBtn); scanQRBtn = findViewById(R.id.scanQRButton); @@ -69,9 +71,25 @@ public class MainActivity extends AppCompatActivity { deviceIdTxt = findViewById(R.id.deviceIdTxt); copyDeviceIdImgBtn = findViewById(R.id.copyDeviceIdImgBtn); defaultSimSlotRadioGroup = findViewById(R.id.defaultSimSlotRadioGroup); + appVersionNameTxt = findViewById(R.id.appVersionNameTxt); + appVersionCodeTxt = findViewById(R.id.appVersionCodeTxt); + checkUpdatesBtn = findViewById(R.id.checkUpdatesBtn); deviceIdTxt.setText(deviceId); + deviceIdEditText.setText(deviceId); deviceBrandAndModelTxt.setText(Build.BRAND + " " + Build.MODEL); + + // Set app version information + String versionName = BuildConfig.VERSION_NAME; + appVersionNameTxt.setText(versionName); + appVersionCodeTxt.setText(String.valueOf(BuildConfig.VERSION_CODE)); + + // Initialize Crashlytics with user information + FirebaseCrashlytics crashlytics = FirebaseCrashlytics.getInstance(); + crashlytics.setCustomKey("device_id", deviceId != null ? deviceId : "not_registered"); + crashlytics.setCustomKey("device_model", Build.MODEL); + crashlytics.setCustomKey("app_version", versionName); + crashlytics.setCustomKey("app_version_code", BuildConfig.VERSION_CODE); if (deviceId == null || deviceId.isEmpty()) { registerDeviceBtn.setText("Register"); @@ -117,7 +135,7 @@ public class MainActivity extends AppCompatActivity { public void onResponse(Call call, Response response) { Log.d(TAG, response.toString()); if (!response.isSuccessful()) { - Snackbar.make(view, response.message(), Snackbar.LENGTH_LONG).show(); + Snackbar.make(view, response.message().isEmpty() ? "An error occurred :( "+ response.code() : response.message(), Snackbar.LENGTH_LONG).show(); compoundButton.setEnabled(true); return; } @@ -137,6 +155,7 @@ public class MainActivity extends AppCompatActivity { Snackbar.make(view, "An error occurred :(", Snackbar.LENGTH_LONG).show(); Log.e(TAG, "API_ERROR "+ t.getMessage()); Log.e(TAG, "API_ERROR "+ t.getLocalizedMessage()); + TextBeeUtils.logException(t, "Error updating device"); compoundButton.setEnabled(true); } }); @@ -165,29 +184,50 @@ public class MainActivity extends AppCompatActivity { intentIntegrator.setRequestCode(SCAN_QR_REQUEST_CODE); intentIntegrator.initiateScan(); }); + + checkUpdatesBtn.setOnClickListener(view -> { + String versionInfo = BuildConfig.VERSION_NAME + "(" + BuildConfig.VERSION_CODE + ")"; + String encodedVersionInfo = android.net.Uri.encode(versionInfo); + String downloadUrl = "https://textbee.dev/download?currentVersion=" + encodedVersionInfo; + Intent browserIntent = new Intent(Intent.ACTION_VIEW, android.net.Uri.parse(downloadUrl)); + startActivity(browserIntent); + }); } private void renderAvailableSimOptions() { try { defaultSimSlotRadioGroup.removeAllViews(); + + // Set radio group styling for dark mode compatibility + defaultSimSlotRadioGroup.setBackgroundColor(getResources().getColor(R.color.background_secondary)); + defaultSimSlotRadioGroup.setPadding(16, 8, 16, 8); + + // Create the default radio button with proper styling RadioButton defaultSimSlotRadioBtn = new RadioButton(mContext); defaultSimSlotRadioBtn.setText("Device Default"); defaultSimSlotRadioBtn.setId((int)123456); + applyRadioButtonStyle(defaultSimSlotRadioBtn); defaultSimSlotRadioGroup.addView(defaultSimSlotRadioBtn); + + // Create radio buttons for each SIM with proper styling TextBeeUtils.getAvailableSimSlots(mContext).forEach(subscriptionInfo -> { String simInfo = "SIM " + (subscriptionInfo.getSimSlotIndex() + 1) + " (" + subscriptionInfo.getDisplayName() + ")"; RadioButton radioButton = new RadioButton(mContext); radioButton.setText(simInfo); radioButton.setId(subscriptionInfo.getSubscriptionId()); + applyRadioButtonStyle(radioButton); defaultSimSlotRadioGroup.addView(radioButton); }); + // Check the preferred SIM based on saved preferences int preferredSim = SharedPreferenceHelper.getSharedPreferenceInt(mContext, AppConstants.SHARED_PREFS_PREFERRED_SIM_KEY, -1); if (preferredSim == -1) { defaultSimSlotRadioGroup.check(defaultSimSlotRadioBtn.getId()); } else { defaultSimSlotRadioGroup.check(preferredSim); } + + // Set the listener for SIM selection changes defaultSimSlotRadioGroup.setOnCheckedChangeListener((radioGroup, i) -> { RadioButton radioButton = findViewById(i); if (radioButton == null) { @@ -205,6 +245,42 @@ public class MainActivity extends AppCompatActivity { Log.e(TAG, "SIM_SLOT_ERROR "+ e.getMessage()); } } + + /** + * Apply the custom radio button style to a programmatically created radio button + */ + private void applyRadioButtonStyle(RadioButton radioButton) { + // Set text color using the color state list for proper dark/light mode handling + setRadioButtonTextColor(radioButton); + + // Set button tint for the radio circle + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + radioButton.setButtonTintList(getResources().getColorStateList(R.color.radio_button_tint, getTheme())); + } else { + radioButton.setButtonTintList(getResources().getColorStateList(R.color.radio_button_tint)); + } + } + + // Add proper padding for better touch experience + radioButton.setPadding( + radioButton.getPaddingLeft() + 8, + radioButton.getPaddingTop() + 12, + radioButton.getPaddingRight(), + radioButton.getPaddingBottom() + 12 + ); + } + + /** + * Helper method to set radio button text color in a backward-compatible way + */ + private void setRadioButtonTextColor(RadioButton radioButton) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + radioButton.setTextColor(getResources().getColorStateList(R.color.radio_button_text_color, getTheme())); + } else { + radioButton.setTextColor(getResources().getColorStateList(R.color.radio_button_text_color)); + } + } @Override public void onRequestPermissionsResult(int requestCode, String permissions[], int[] grantResults) { @@ -225,8 +301,9 @@ public class MainActivity extends AppCompatActivity { } private void handleRegisterDevice() { - String newKey = apiKeyEditText.getText().toString(); + String deviceIdInput = deviceIdEditText.getText().toString(); + registerDeviceBtn.setEnabled(false); registerDeviceBtn.setText("Loading..."); View view = findViewById(R.id.registerDeviceBtn); @@ -252,6 +329,50 @@ public class MainActivity extends AppCompatActivity { registerDeviceInput.setOs(Build.VERSION.BASE_OS); registerDeviceInput.setAppVersionCode(BuildConfig.VERSION_CODE); registerDeviceInput.setAppVersionName(BuildConfig.VERSION_NAME); + + // If the user provided a device ID, use it for updating instead of creating new + if (!deviceIdInput.isEmpty()) { + Log.d(TAG, "Updating device with deviceId: "+ deviceIdInput); + Call apiCall = ApiManager.getApiService().updateDevice(deviceIdInput, newKey, registerDeviceInput); + apiCall.enqueue(new Callback() { + @Override + public void onResponse(Call call, Response response) { + Log.d(TAG, response.toString()); + if (!response.isSuccessful()) { + Snackbar.make(view, response.message().isEmpty() ? "An error occurred :( "+ response.code() : response.message(), Snackbar.LENGTH_LONG).show(); + registerDeviceBtn.setEnabled(true); + registerDeviceBtn.setText("Update"); + return; + } + SharedPreferenceHelper.setSharedPreferenceString(mContext, AppConstants.SHARED_PREFS_API_KEY_KEY, newKey); + Snackbar.make(view, "Device Updated Successfully :)", Snackbar.LENGTH_LONG).show(); + + // Update deviceId from response if available + if (response.body() != null && response.body().data != null && response.body().data.get("_id") != null) { + deviceId = response.body().data.get("_id").toString(); + deviceIdTxt.setText(deviceId); + deviceIdEditText.setText(deviceId); + SharedPreferenceHelper.setSharedPreferenceString(mContext, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, deviceId); + SharedPreferenceHelper.setSharedPreferenceBoolean(mContext, AppConstants.SHARED_PREFS_GATEWAY_ENABLED_KEY, registerDeviceInput.isEnabled()); + gatewaySwitch.setChecked(registerDeviceInput.isEnabled()); + } + + registerDeviceBtn.setEnabled(true); + registerDeviceBtn.setText("Update"); + } + + @Override + public void onFailure(Call call, Throwable t) { + Snackbar.make(view, "An error occurred :(", Snackbar.LENGTH_LONG).show(); + Log.e(TAG, "API_ERROR "+ t.getMessage()); + Log.e(TAG, "API_ERROR "+ t.getLocalizedMessage()); + TextBeeUtils.logException(t, "Error registering device"); + registerDeviceBtn.setEnabled(true); + registerDeviceBtn.setText("Update"); + } + }); + return; + } Call apiCall = ApiManager.getApiService().registerDevice(newKey, registerDeviceInput); apiCall.enqueue(new Callback() { @@ -259,25 +380,32 @@ public class MainActivity extends AppCompatActivity { public void onResponse(Call call, Response response) { Log.d(TAG, response.toString()); if (!response.isSuccessful()) { - Snackbar.make(view, response.message(), Snackbar.LENGTH_LONG).show(); + Snackbar.make(view, response.message().isEmpty() ? "An error occurred :( "+ response.code() : response.message(), Snackbar.LENGTH_LONG).show(); registerDeviceBtn.setEnabled(true); registerDeviceBtn.setText("Update"); return; } SharedPreferenceHelper.setSharedPreferenceString(mContext, AppConstants.SHARED_PREFS_API_KEY_KEY, newKey); Snackbar.make(view, "Device Registration Successful :)", Snackbar.LENGTH_LONG).show(); - deviceId = response.body().data.get("_id").toString(); - deviceIdTxt.setText(deviceId); - SharedPreferenceHelper.setSharedPreferenceString(mContext, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, deviceId); + + if (response.body() != null && response.body().data != null && response.body().data.get("_id") != null) { + deviceId = response.body().data.get("_id").toString(); + deviceIdTxt.setText(deviceId); + deviceIdEditText.setText(deviceId); + SharedPreferenceHelper.setSharedPreferenceString(mContext, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, deviceId); + SharedPreferenceHelper.setSharedPreferenceBoolean(mContext, AppConstants.SHARED_PREFS_GATEWAY_ENABLED_KEY, registerDeviceInput.isEnabled()); + gatewaySwitch.setChecked(registerDeviceInput.isEnabled()); + } + registerDeviceBtn.setEnabled(true); registerDeviceBtn.setText("Update"); - } @Override public void onFailure(Call call, Throwable t) { Snackbar.make(view, "An error occurred :(", Snackbar.LENGTH_LONG).show(); Log.e(TAG, "API_ERROR "+ t.getMessage()); Log.e(TAG, "API_ERROR "+ t.getLocalizedMessage()); + TextBeeUtils.logException(t, "Error registering device"); registerDeviceBtn.setEnabled(true); registerDeviceBtn.setText("Update"); } @@ -287,6 +415,9 @@ public class MainActivity extends AppCompatActivity { private void handleUpdateDevice() { String apiKey = apiKeyEditText.getText().toString(); + String deviceIdInput = deviceIdEditText.getText().toString(); + String deviceIdToUse = !deviceIdInput.isEmpty() ? deviceIdInput : deviceId; + registerDeviceBtn.setEnabled(false); registerDeviceBtn.setText("Loading..."); View view = findViewById(R.id.registerDeviceBtn); @@ -313,18 +444,27 @@ public class MainActivity extends AppCompatActivity { updateDeviceInput.setAppVersionCode(BuildConfig.VERSION_CODE); updateDeviceInput.setAppVersionName(BuildConfig.VERSION_NAME); - Call apiCall = ApiManager.getApiService().updateDevice(deviceId, apiKey, updateDeviceInput); + Call apiCall = ApiManager.getApiService().updateDevice(deviceIdToUse, apiKey, updateDeviceInput); apiCall.enqueue(new Callback() { @Override public void onResponse(Call call, Response response) { Log.d(TAG, response.toString()); if (!response.isSuccessful()) { - Snackbar.make(view, response.message(), Snackbar.LENGTH_LONG).show(); + Snackbar.make(view, response.message().isEmpty() ? "An error occurred :( "+ response.code() : response.message(), Snackbar.LENGTH_LONG).show(); registerDeviceBtn.setEnabled(true); registerDeviceBtn.setText("Update"); return; } SharedPreferenceHelper.setSharedPreferenceString(mContext, AppConstants.SHARED_PREFS_API_KEY_KEY, apiKey); + + // Update deviceId from response if available + if (response.body() != null && response.body().data != null && response.body().data.get("_id") != null) { + deviceId = response.body().data.get("_id").toString(); + SharedPreferenceHelper.setSharedPreferenceString(mContext, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, deviceId); + deviceIdTxt.setText(deviceId); + deviceIdEditText.setText(deviceId); + } + Snackbar.make(view, "Device Updated Successfully :)", Snackbar.LENGTH_LONG).show(); registerDeviceBtn.setEnabled(true); registerDeviceBtn.setText("Update"); @@ -335,6 +475,7 @@ public class MainActivity extends AppCompatActivity { Snackbar.make(view, "An error occurred :(", Snackbar.LENGTH_LONG).show(); Log.e(TAG, "API_ERROR "+ t.getMessage()); Log.e(TAG, "API_ERROR "+ t.getLocalizedMessage()); + TextBeeUtils.logException(t, "Error updating device"); registerDeviceBtn.setEnabled(true); registerDeviceBtn.setText("Update"); } @@ -364,7 +505,11 @@ public class MainActivity extends AppCompatActivity { } String scannedQR = intentResult.getContents(); apiKeyEditText.setText(scannedQR); - handleRegisterDevice(); + if(deviceIdEditText.getText().toString().isEmpty()) { + handleRegisterDevice(); + } else { + handleUpdateDevice(); + } } } diff --git a/android/app/src/main/res/color/radio_button_text_color.xml b/android/app/src/main/res/color/radio_button_text_color.xml new file mode 100644 index 0000000..6195d1e --- /dev/null +++ b/android/app/src/main/res/color/radio_button_text_color.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/color/radio_button_tint.xml b/android/app/src/main/res/color/radio_button_tint.xml new file mode 100644 index 0000000..6df443e --- /dev/null +++ b/android/app/src/main/res/color/radio_button_tint.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/drawable/ic_baseline_edit_24.xml b/android/app/src/main/res/drawable/ic_baseline_edit_24.xml new file mode 100644 index 0000000..d85cee2 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_baseline_edit_24.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/android/app/src/main/res/drawable/ic_baseline_info_24.xml b/android/app/src/main/res/drawable/ic_baseline_info_24.xml new file mode 100644 index 0000000..2dbade6 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_baseline_info_24.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml index f311a96..55d25ae 100644 --- a/android/app/src/main/res/layout/activity_main.xml +++ b/android/app/src/main/res/layout/activity_main.xml @@ -1,164 +1,122 @@ - - - + + android:layout_height="wrap_content" + android:orientation="vertical"> + + android:background="?attr/colorPrimary" + android:orientation="vertical" + android:padding="24dp"> - + android:textColor="@color/white" + android:textSize="22sp" + android:textStyle="bold" /> - - - - - - - - -