diff --git a/.gitmodules b/.gitmodules index 004cd80..03fa19b 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,6 @@ [submodule "EventBus"] path = EventBus url = https://github.com/utkabobr/EventBus +[submodule "SAPIL"] + path = SAPIL + url = https://github.com/utkabobr/SAPIL diff --git a/SAPIL b/SAPIL new file mode 160000 index 0000000..d0c6422 --- /dev/null +++ b/SAPIL @@ -0,0 +1 @@ +Subproject commit d0c6422d793eb4c635ce2effd1f8db8d27cd0330 diff --git a/app/build.gradle b/app/build.gradle index bc29161..06c54ae 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -6,14 +6,14 @@ def commit = getGitCommitHash(file('.')) android { namespace 'ru.ytkab0bp.slicebeam' - compileSdk 34 + compileSdk 35 defaultConfig { applicationId "ru.ytkab0bp.slicebeam" minSdk 21 - targetSdk 34 - versionCode 6 - versionName "0.2.0" + targetSdk 35 + versionCode 8 + versionName "0.3.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" @@ -90,12 +90,14 @@ dependencies { implementation project(":eventbus") implementation project(":eventbus_api") annotationProcessor project(":eventbus_processor") - implementation 'com.github.instacart:truetime-android:3.5' + implementation project(":sapil") + implementation 'com.google.code.gson:gson:2.11.0' + implementation 'com.github.instacart:truetime-android:4.0.0.alpha' implementation 'com.github.mrudultora:Colorpicker:1.2.0' implementation 'com.github.bumptech.glide:glide:4.16.0' implementation 'androidx.appcompat:appcompat:1.7.0' implementation 'com.google.android.material:material:1.12.0' implementation 'com.loopj.android:android-async-http:1.4.11' - implementation 'androidx.activity:activity:1.9.1' + implementation 'androidx.activity:activity:1.10.1' } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ebc960d..aae03d8 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -5,7 +5,15 @@ - + + + + + + + + + EXPORTING_FILAMENTS; public static List EXPORTING_PRINTERS; + public static File aiTempFile; + private static SparseArray liveDelegate = new SparseArray<>(); private static int lastId; @@ -317,6 +325,23 @@ public class MainActivity extends AppCompatActivity { .setPositiveButton(android.R.string.ok, null) .show(); } + } else if (requestCode == REQUEST_CODE_AI_GENERATOR_TAKE_PHOTO) { + SliceBeam.EVENT_BUS.fireEvent(new NeedDismissAIGeneratorMenu()); + + Bitmap bm = BitmapFactory.decodeFile(aiTempFile.getAbsolutePath()); + generateAiModel(bm); + aiTempFile.delete(); + aiTempFile = null; + } else if (requestCode == REQUEST_CODE_AI_GENERATOR_CHOOSE_PHOTO) { + SliceBeam.EVENT_BUS.fireEvent(new NeedDismissAIGeneratorMenu()); + + try { + InputStream in = getContentResolver().openInputStream(data.getData()); + Bitmap bm = BitmapFactory.decodeStream(in); + generateAiModel(bm); + } catch (Exception e) { + Log.e("ai_generator", "Failed to write to downloads", e); + } } } } @@ -462,21 +487,106 @@ public class MainActivity extends AppCompatActivity { } }); } - .show(); - return; + + private void generateAiModel(Bitmap bm) { + String uploadTag = UUID.randomUUID().toString(); + SliceBeam.EVENT_BUS.fireEvent(new NeedSnackbarEvent(SnackbarsLayout.Type.LOADING, R.string.MenuFileAIGeneratorUploading).tag(uploadTag)); + IOUtils.IO_POOL.submit(()->{ + Bitmap scaled; + if (bm.getWidth() > 1024 || bm.getHeight() > 1024) { + if (bm.getWidth() > bm.getHeight()) { + int w = 1024; + int h = (int) ((float) w * bm.getHeight() / bm.getWidth()); + scaled = Bitmap.createScaledBitmap(bm, w, h, true); + } else { + int h = 1024; + int w = (int) ((float) h * bm.getWidth() / bm.getHeight()); + scaled = Bitmap.createScaledBitmap(bm, w, h, true); + } + bm.recycle(); + } else { + scaled = bm; + } + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + scaled.compress(Bitmap.CompressFormat.PNG, 100, out); + scaled.recycle(); + + String processTag = UUID.randomUUID().toString(); + CloudAPI.INSTANCE.modelsGenerate(Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP), "image/png", new APICallback() { + @Override + public void onResponse(InputStream in) { + SliceBeam.EVENT_BUS.fireEvent(new NeedDismissSnackbarEvent(processTag)); + + String downloadTag = UUID.randomUUID().toString(); + SliceBeam.EVENT_BUS.fireEvent(new NeedSnackbarEvent(SnackbarsLayout.Type.LOADING, R.string.MenuFileAIGeneratorDownloading).tag(downloadTag)); + String fileName = "generated_" + UUID.randomUUID() + ".stl"; + + File f = new File(SliceBeam.getModelCacheDir(), fileName); + try { + FileOutputStream fos = new FileOutputStream(f); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + ContentValues contentValues = new ContentValues(); + contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, fileName); + contentValues.put(MediaStore.MediaColumns.MIME_TYPE, "application/vnd.ms-pki.stl"); + contentValues.put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS); + + Uri uri = getContentResolver().insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues); + + if (uri != null) { + try { + OutputStream out = getContentResolver().openOutputStream(uri); + byte[] buf = new byte[10240]; + int c; + while ((c = in.read(buf)) != -1) { + out.write(buf, 0, c); + fos.write(buf, 0, c); + } + out.close(); + } catch (IOException e) { + Log.e("ai_generator", "Failed to write to downloads", e); + } + } + } else { + File downloadsDirectory = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS); + File file = new File(downloadsDirectory, fileName); + + try { + FileOutputStream out = new FileOutputStream(file); + byte[] buf = new byte[10240]; + int c; + while ((c = in.read(buf)) != -1) { + out.write(buf, 0, c); + fos.write(buf, 0, c); + } + out.close(); + } catch (IOException e) { + Log.e("ai_generator", "Failed to write to downloads", e); + } + } + fos.close(); + } catch (Exception e) { + Log.e("ai_generator", "Failed to write to downloads", e); + } + SliceBeam.EVENT_BUS.fireEvent(new NeedDismissSnackbarEvent(downloadTag)); + SliceBeam.EVENT_BUS.fireEvent(new NeedSnackbarEvent(R.string.MenuFileAIGeneratorSavedAs, fileName)); + loadFile(f, true); + CloudController.checkGeneratorRemaining(); } - try { - loadIniForImport(resolver.openInputStream(uri)); - } catch (FileNotFoundException e) { - new BeamAlertDialogBuilder(this) - .setTitle(R.string.MenuFileImportProfilesFailed) + @Override + public void onException(Exception e) { + SliceBeam.EVENT_BUS.fireEvent(new NeedDismissSnackbarEvent(processTag)); + ViewUtils.postOnMainThread(() -> new BeamAlertDialogBuilder(MainActivity.this) + .setTitle(R.string.MenuFileAIGeneratorError) .setMessage(e.toString()) .setPositiveButton(android.R.string.ok, null) - .show(); + .show()); } - } - } + }); + SliceBeam.EVENT_BUS.fireEvent(new NeedDismissSnackbarEvent(uploadTag)); + SliceBeam.EVENT_BUS.fireEvent(new NeedSnackbarEvent(SnackbarsLayout.Type.LOADING, R.string.MenuFileAIGeneratorProcessing).tag(processTag)); + }); } private void loadIniForImport(InputStream in) { diff --git a/app/src/main/java/ru/ytkab0bp/slicebeam/SetupActivity.java b/app/src/main/java/ru/ytkab0bp/slicebeam/SetupActivity.java index ab17de4..a6201de 100644 --- a/app/src/main/java/ru/ytkab0bp/slicebeam/SetupActivity.java +++ b/app/src/main/java/ru/ytkab0bp/slicebeam/SetupActivity.java @@ -1,29 +1,34 @@ package ru.ytkab0bp.slicebeam; -import static android.opengl.GLES30.*; +import static android.opengl.GLES30.GL_COLOR_BUFFER_BIT; +import static android.opengl.GLES30.GL_DEPTH_BUFFER_BIT; +import static android.opengl.GLES30.GL_DEPTH_TEST; +import static android.opengl.GLES30.glClear; +import static android.opengl.GLES30.glClearColor; +import static android.opengl.GLES30.glDisable; +import static android.opengl.GLES30.glEnable; +import static android.opengl.GLES30.glViewport; +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; import android.annotation.SuppressLint; import android.content.Context; import android.content.Intent; import android.content.pm.PackageInfo; import android.content.pm.PackageManager; import android.content.res.ColorStateList; -import android.graphics.Canvas; import android.graphics.Color; -import android.graphics.Paint; import android.graphics.Typeface; import android.graphics.drawable.Drawable; import android.net.Uri; import android.opengl.GLSurfaceView; import android.os.Bundle; import android.text.SpannableStringBuilder; -import android.text.Spanned; import android.text.TextUtils; -import android.text.style.ImageSpan; -import android.text.style.ReplacementSpan; import android.util.Log; import android.util.TypedValue; import android.view.Gravity; +import android.view.MotionEvent; import android.view.SurfaceHolder; import android.view.View; import android.view.ViewGroup; @@ -45,6 +50,7 @@ import androidx.core.view.ViewCompat; import androidx.dynamicanimation.animation.FloatValueHolder; import androidx.dynamicanimation.animation.SpringAnimation; import androidx.dynamicanimation.animation.SpringForce; +import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import androidx.viewpager2.widget.ViewPager2; @@ -82,10 +88,15 @@ import javax.microedition.khronos.opengles.GL10; import cz.msebera.android.httpclient.Header; import ru.ytkab0bp.eventbus.EventHandler; +import ru.ytkab0bp.slicebeam.cloud.CloudAPI; +import ru.ytkab0bp.slicebeam.cloud.CloudController; import ru.ytkab0bp.slicebeam.components.BeamAlertDialogBuilder; +import ru.ytkab0bp.slicebeam.components.CloudManageBottomSheet; import ru.ytkab0bp.slicebeam.config.ConfigObject; import ru.ytkab0bp.slicebeam.events.BeamServerDataUpdatedEvent; +import ru.ytkab0bp.slicebeam.events.CloudLoginStateUpdatedEvent; import ru.ytkab0bp.slicebeam.recycler.BigHeaderItem; +import ru.ytkab0bp.slicebeam.recycler.PreferenceItem; import ru.ytkab0bp.slicebeam.recycler.SimpleRecyclerAdapter; import ru.ytkab0bp.slicebeam.recycler.SimpleRecyclerItem; import ru.ytkab0bp.slicebeam.recycler.TextHintRecyclerItem; @@ -108,6 +119,7 @@ import ru.ytkab0bp.slicebeam.view.TextColorImageSpan; public class SetupActivity extends AppCompatActivity { public final static String EXTRA_ABOUT = "about"; public final static String EXTRA_BOOSTY_ONLY = "boosty_only"; + public final static String EXTRA_CLOUD_PROFILE = "cloud_profile"; private final static String TAG = "SetupActivity"; @@ -140,6 +152,7 @@ public class SetupActivity extends AppCompatActivity { private List repos = new ArrayList<>(); private ReposItem reposItem; private ProfilesItem profilesItem; + private CloudProfileItem cloudItem; private boolean isReposLoaded; private boolean limitRepoFragmentCount = true; private boolean limitProfileFragmentCount = true; @@ -149,6 +162,7 @@ public class SetupActivity extends AppCompatActivity { private boolean isProfilesLoaded; private boolean about; private boolean boostyOnly; + private boolean cloudProfile; private List enabledPrinters = new ArrayList<>(); @@ -166,8 +180,9 @@ public class SetupActivity extends AppCompatActivity { about = getIntent().getBooleanExtra(EXTRA_ABOUT, false); boostyOnly = getIntent().getBooleanExtra(EXTRA_BOOSTY_ONLY, false); + cloudProfile = getIntent().getBooleanExtra(EXTRA_CLOUD_PROFILE, false); - if (!about && !boostyOnly) { + if (!about && !boostyOnly && !cloudProfile) { new BeamAlertDialogBuilder(this) .setTitle(R.string.IntroEarlyAccess) .setMessage(R.string.IntroEarlyAccessMessage) @@ -175,11 +190,15 @@ public class SetupActivity extends AppCompatActivity { .show(); } + if (boostyOnly || cloudProfile) { + backgroundProgress = 1f; + } + pager = new ViewPager2(this); adapter = new SimpleRecyclerAdapter() { @Override public int getItemCount() { - return about || boostyOnly ? 1 : limitRepoFragmentCount ? REPOS_INDEX + 1 : limitProfileFragmentCount ? PROFILES_INDEX + 1 : super.getItemCount(); + return about || boostyOnly || cloudProfile ? 1 : limitRepoFragmentCount ? REPOS_INDEX + 1 : limitProfileFragmentCount ? PROFILES_INDEX + 1 : super.getItemCount(); } }; setItems(); @@ -210,7 +229,7 @@ public class SetupActivity extends AppCompatActivity { @Override public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) { - if (position == 0 && !boostyOnly) { + if (position == 0 && !boostyOnly && !cloudProfile) { backgroundProgress = positionOffset; } else { backgroundProgress = 1f; @@ -348,14 +367,7 @@ public class SetupActivity extends AppCompatActivity { } } - title.setTranslationY(ViewUtils.lerp(titleY, (ViewUtils.dp(52) - title.getHeight() * title.getScaleY()) / 2f, backgroundProgress)); - float sc = ViewUtils.lerp(1, 22 / 32f, backgroundProgress); - title.setPivotX(title.getWidth() / 2f); - title.setPivotY(0); - title.setScaleX(sc); - title.setScaleY(sc); - int color = ColorUtils.blendARGB(ThemesRepo.getColor(R.attr.textColorOnAccent), ThemesRepo.getColor(android.R.attr.colorAccent), backgroundProgress - boostyProgress); - title.setTextColor(color); + invalidateTitleY(); backgroundView.requestRender(); } }); @@ -368,7 +380,7 @@ public class SetupActivity extends AppCompatActivity { super.onSizeChanged(w, h, oldw, oldh); titleY = h / 4; - title.setTranslationY(ViewUtils.lerp(titleY, title.getPaddingTop(), backgroundProgress)); + invalidateTitleY(); } }; fl.setClipChildren(false); @@ -417,10 +429,13 @@ public class SetupActivity extends AppCompatActivity { topColor = ColorUtils.blendARGB(bottomColor, ThemesRepo.getColor(R.attr.boostyColorTop), boostyProgress); bottomColor = ColorUtils.blendARGB(bottomColor, ThemesRepo.getColor(R.attr.boostyColorBottom), boostyProgress); } + if (cloudProfile) { + bottomColor = ColorUtils.blendARGB(bottomColor, topColor, 0.5f); + } shader.setUniformColor("top_color", topColor); shader.setUniformColor("bottom_color", bottomColor); - shader.setUniform("progress", backgroundProgress - (boostyProgress != 0 ? 1.2f : 0)); + shader.setUniform("progress", backgroundProgress - (cloudProfile ? 1.4f : 0) - (boostyProgress != 0 ? 1.2f : 0)); shader.setUniform("time", time); backgroundModel.render(); shader.stopUsing(); @@ -434,7 +449,7 @@ public class SetupActivity extends AppCompatActivity { title = new TextView(this); title.setGravity(Gravity.CENTER); title.setTypeface(Typeface.DEFAULT_BOLD); - title.setText(R.string.AppName); + title.setText(cloudProfile ? R.string.SettingsCloudManageTitle : R.string.AppName); title.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 32); title.setTextColor(ThemesRepo.getColor(R.attr.textColorOnAccent)); title.setLayoutParams(new FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.CENTER_HORIZONTAL)); @@ -459,6 +474,17 @@ public class SetupActivity extends AppCompatActivity { } } + private void invalidateTitleY() { + float sc = ViewUtils.lerp(1, 22 / 32f, backgroundProgress); + title.setPivotX(title.getWidth() / 2f); + title.setPivotY(0); + title.setScaleX(sc); + title.setScaleY(sc); + int color = ColorUtils.blendARGB(ThemesRepo.getColor(R.attr.textColorOnAccent), ThemesRepo.getColor(android.R.attr.colorAccent), cloudProfile ? 0f : backgroundProgress - boostyProgress); + title.setTextColor(color); + title.setTranslationY(ViewUtils.lerp(titleY, (ViewUtils.dp(52) - title.getHeight() * title.getScaleY()) / 2f, backgroundProgress)); + } + @Override protected void onDestroy() { super.onDestroy(); @@ -468,7 +494,7 @@ public class SetupActivity extends AppCompatActivity { @EventHandler(runOnMainThread = true) public void onDataUpdated(BeamServerDataUpdatedEvent e) { - if (!about) { + if (!about && !boostyOnly && !cloudProfile) { boolean wasBoosty = BOOSTY_INDEX != -1; if (wasBoosty != BeamServerData.isBoostyAvailable()) { setItems(); @@ -476,8 +502,18 @@ public class SetupActivity extends AppCompatActivity { } } + @EventHandler(runOnMainThread = true) + public void onCloudAuthStateUpdated(CloudLoginStateUpdatedEvent e) { + if (cloudProfile) { + cloudItem.bindLoginButton(true); + cloudItem.bindFeatures(); + } + } + private void setItems() { - if (boostyOnly) { + if (cloudProfile){ + adapter.setItems(Collections.singletonList(cloudItem = new CloudProfileItem())); + } else if (boostyOnly) { adapter.setItems(Collections.singletonList(new BoostyItem())); } else if (about) { adapter.setItems(Collections.singletonList(new AboutItem())); @@ -613,6 +649,323 @@ public class SetupActivity extends AppCompatActivity { fakeScroller.start(); } + private final class CloudProfileItem extends SimpleRecyclerItem { + private FrameLayout buttonView; + private TextView buttonText; + private ProgressBar buttonProgress; + private RecyclerView recyclerView; + + @Override + public View onCreateView(Context ctx) { + LinearLayout ll = new LinearLayout(ctx); + ll.setOrientation(LinearLayout.VERTICAL); + ll.setPadding(0, ViewUtils.dp(42), 0, 0); + + TextView title = new TextView(ctx); + title.setTextColor(ThemesRepo.getColor(R.attr.textColorOnAccent)); + title.setText(R.string.SettingsCloudManageDescription); + title.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 16); + title.setGravity(Gravity.CENTER); + title.setPadding(ViewUtils.dp(12), 0, ViewUtils.dp(12), 0); + ll.addView(title); + + FrameLayout fl = new FrameLayout(ctx); + recyclerView = new RecyclerView(ctx); + recyclerView.setLayoutManager(new LinearLayoutManager(ctx)); + recyclerView.setAdapter(adapter = new SimpleRecyclerAdapter()); + recyclerView.setOverScrollMode(View.OVER_SCROLL_NEVER); + fl.addView(recyclerView, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.CENTER)); + bindFeatures(); + + ll.addView(fl, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)); + + TextView tosButton = new TextView(ctx); + SpannableStringBuilder sb = SpannableStringBuilder.valueOf(ctx.getString(R.string.SettingsCloudManageTermsOfService)).append(" "); + Drawable dr = ContextCompat.getDrawable(ctx, R.drawable.external_link_outline_24); + int size = ViewUtils.dp(16); + dr.setBounds(0, 0, size, size); + sb.append("d", new TextColorImageSpan(dr, ViewUtils.dp(2f)), SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE); + tosButton.setText(sb); + tosButton.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 15); + tosButton.setTextColor(Color.WHITE); + tosButton.setTypeface(ViewUtils.getTypeface(ViewUtils.ROBOTO_MEDIUM)); + tosButton.setGravity(Gravity.CENTER); + tosButton.setPadding(ViewUtils.dp(12), ViewUtils.dp(8), ViewUtils.dp(12), ViewUtils.dp(8)); + tosButton.setBackground(ViewUtils.createRipple(ThemesRepo.getColor(android.R.attr.colorControlHighlight), 16)); + tosButton.setOnClickListener(v -> startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse("https://beam3d.ru/slicebeam_cloud_tos.html")))); + ll.addView(tosButton, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewUtils.dp(52)) {{ + leftMargin = rightMargin = ViewUtils.dp(16); + bottomMargin = ViewUtils.dp(8); + }}); + + buttonView = new FrameLayout(ctx); + buttonView.setBackground(ViewUtils.createRipple(ThemesRepo.getColor(android.R.attr.colorControlHighlight), ThemesRepo.getColor(android.R.attr.colorAccent), 16)); + + buttonText = new TextView(ctx); + buttonText.setTextColor(ThemesRepo.getColor(R.attr.textColorOnAccent)); + buttonText.setTypeface(ViewUtils.getTypeface(ViewUtils.ROBOTO_MEDIUM)); + buttonText.setGravity(Gravity.CENTER); + buttonText.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 16); + buttonView.addView(buttonText, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.CENTER)); + + buttonProgress = new ProgressBar(ctx); + buttonProgress.setIndeterminateTintList(ColorStateList.valueOf(ThemesRepo.getColor(R.attr.textColorOnAccent))); + buttonView.addView(buttonProgress, new FrameLayout.LayoutParams(ViewUtils.dp(28), ViewUtils.dp(28), Gravity.CENTER)); + + bindLoginButton(false); + + ll.addView(buttonView, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewUtils.dp(52)) {{ + leftMargin = rightMargin = ViewUtils.dp(16); + bottomMargin = ViewUtils.dp(16); + }}); + + ll.setLayoutParams(new RecyclerView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); + return ll; + } + + private void bindFeatures() { + List items = new ArrayList<>(); + if (CloudController.getUserFeatures() != null) { + for (CloudAPI.SubscriptionLevel lvl : CloudController.getUserFeatures().levels) { + items.add(new CloudSubscriptionLevel(lvl)); + } + } + adapter.setItems(items); + } + + private void bindLoginButton(boolean animate) { + boolean loggedIn = Prefs.getCloudAPIToken() != null; + boolean loading = !loggedIn && CloudController.isLoggingIn(); + boolean wasLoading = buttonProgress.getTag() != null; + if (animate) { + if (wasLoading != loading) { + buttonProgress.setTag(loading ? 1 : null); + + buttonProgress.animate().cancel(); + buttonProgress.animate().scaleX(loading ? 1f : 0.4f).scaleY(loading ? 1f : 0.4f).alpha(loading ? 1f : 0f).setDuration(150).setInterpolator(ViewUtils.CUBIC_INTERPOLATOR).setListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationStart(Animator animation) { + if (loading) { + buttonProgress.setVisibility(View.VISIBLE); + buttonProgress.setAlpha(0f); + buttonProgress.setScaleX(0.4f); + buttonProgress.setScaleY(0.4f); + } + } + + @Override + public void onAnimationEnd(Animator animation) { + if (!loading) { + buttonProgress.setVisibility(View.GONE); + } + } + }).start(); + + buttonText.animate().cancel(); + buttonText.animate().scaleX(!loading ? 1f : 0.4f).scaleY(!loading ? 1f : 0.4f).alpha(!loading ? 1f : 0f).setDuration(150).setInterpolator(ViewUtils.CUBIC_INTERPOLATOR).setListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationStart(Animator animation) { + if (!loading) { + buttonText.setVisibility(View.VISIBLE); + buttonText.setAlpha(0f); + buttonText.setScaleX(0.4f); + buttonText.setScaleY(0.4f); + } + } + + @Override + public void onAnimationEnd(Animator animation) { + if (loading) { + buttonText.setVisibility(View.GONE); + } + } + }).start(); + } + } else { + buttonProgress.setTag(loading ? 1 : null); + buttonProgress.setVisibility(loading ? View.VISIBLE : View.GONE); + buttonText.setVisibility(loading ? View.GONE : View.VISIBLE); + } + buttonText.setText(loggedIn ? R.string.SettingsCloudManageButtonManage : R.string.SettingsCloudManageButtonLogIn); + buttonView.setOnClickListener(v-> { + if (loading) { + new BeamAlertDialogBuilder(v.getContext()) + .setTitle(R.string.SettingsCloudManageButtonLogInCancelTitle) + .setMessage(R.string.SettingsCloudManageButtonLogInCancel) + .setNegativeButton(R.string.No, null) + .setPositiveButton(R.string.Yes, (dialog, which) -> CloudController.cancelLogin()) + .show(); + } else if (Prefs.getCloudAPIToken() != null) { + new CloudManageBottomSheet(v.getContext()).show(); + } else { + CloudController.beginLogin(); + } + }); + } + } + + private final static class CloudSubscriptionLevel extends SimpleRecyclerItem { + private CloudAPI.SubscriptionLevel level; + + private CloudSubscriptionLevel(CloudAPI.SubscriptionLevel level) { + this.level = level; + } + + @Override + public LevelHolderView onCreateView(Context ctx) { + return new LevelHolderView(ctx); + } + + @Override + public void onBindView(LevelHolderView view) { + view.bind(this); + } + + public final static class LevelHolderView extends LinearLayout implements IThemeView { + private ImageView icon; + private TextView title; + private TextView price; + + private RecyclerView featuresLayout; + private SimpleRecyclerAdapter featuresAdapter; + + public LevelHolderView(@NonNull Context context) { + super(context); + + setOrientation(VERTICAL); + setPadding(0, ViewUtils.dp(16), 0, ViewUtils.dp(8)); + + LinearLayout inner = new LinearLayout(context); + inner.setOrientation(HORIZONTAL); + inner.setGravity(Gravity.CENTER_VERTICAL); + inner.setPadding(ViewUtils.dp(28), 0, ViewUtils.dp(28), 0); + addView(inner, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) {{ + bottomMargin = ViewUtils.dp(8); + }}); + + icon = new ImageView(context); + inner.addView(icon, new LayoutParams(ViewUtils.dp(26), ViewUtils.dp(26))); + + title = new TextView(context); + title.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 16); + title.setTypeface(ViewUtils.getTypeface(ViewUtils.ROBOTO_MEDIUM)); + inner.addView(title, new LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f) {{ + leftMargin = ViewUtils.dp(12); + }}); + + price = new TextView(context); + price.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 16); + price.setTypeface(ViewUtils.getTypeface(ViewUtils.ROBOTO_MEDIUM)); + inner.addView(price); + + featuresLayout = new RecyclerView(context) { + @Override + public boolean dispatchTouchEvent(MotionEvent ev) { + return false; + } + + @Override + protected boolean dispatchHoverEvent(MotionEvent event) { + return false; + } + }; + featuresLayout.setLayoutManager(new LinearLayoutManager(context)); + featuresLayout.setAdapter(featuresAdapter = new SimpleRecyclerAdapter()); + addView(featuresLayout, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) {{ + topMargin = ViewUtils.dp(3); + leftMargin = rightMargin = ViewUtils.dp(16); + bottomMargin = ViewUtils.dp(8); + }}); + + setLayoutParams(new RecyclerView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) {{ + leftMargin = rightMargin = ViewUtils.dp(12); + topMargin = ViewUtils.dp(12); + }}); + onApplyTheme(); + } + + public void bind(CloudSubscriptionLevel item) { + CloudAPI.SubscriptionLevel lvl = item.level; + title.setText(lvl.title); + price.setText(lvl.price); + if (lvl.level <= 0) { + icon.setImageResource(R.drawable.zero_ruble_outline_28); + price.setText(R.string.SettingsCloudManageFree); + } else if (lvl.level == 1) { + icon.setImageResource(R.drawable.stars_outline_28); + } else { + icon.setImageResource(R.drawable.cloud_plus_outline_28); + } + + List items = new ArrayList<>(); + CloudAPI.UserFeatures features = CloudController.getUserFeatures(); + CloudAPI.UserInfo info = CloudController.getUserInfo(); + Context ctx = getContext(); + if (features.syncRequiredLevel != -1 && lvl.level >= features.syncRequiredLevel) { + items.add(new PreferenceItem() + .setForceDark(true) + .setPaddings(ViewUtils.dp(8)) + .setIcon(R.drawable.sync_outline_28) + .setTitle(ctx.getString(R.string.SettingsCloudManageFeatureCloudSync)) + .setSubtitle(ctx.getString(R.string.SettingsCloudManageFeatureCloudSyncDescription))); + } + if (features.aiGeneratorRequiredLevel != -1 && lvl.level >= features.aiGeneratorRequiredLevel) { + items.add(new PreferenceItem() + .setForceDark(true) + .setPaddings(ViewUtils.dp(8)) + .setIcon(R.drawable.brain_outline_28) + .setTitle(ctx.getString(R.string.SettingsCloudManageFeatureAIGenerator)) + .setSubtitle(ctx.getString(R.string.SettingsCloudManageFeatureAIGeneratorDescription, features.aiGeneratorModelsPerMonth))); + } + if (lvl.level > 0) { + items.add(new PreferenceItem() + .setForceDark(true) + .setPaddings(ViewUtils.dp(8)) + .setIcon(R.drawable.box_heart_outline_28) + .setTitle(ctx.getString(R.string.SettingsCloudManageFeatureFreeForAll)) + .setSubtitle(ctx.getString(R.string.SettingsCloudManageFeatureFreeForAllDescription))); + } + featuresAdapter.setItems(items); + featuresLayout.setVisibility(items.isEmpty() ? View.GONE : View.VISIBLE); + + boolean subscribed = lvl.level > 0 && info != null && lvl.level == info.currentLevel; + boolean allowSubscribe = lvl.level > 0 && (info == null || lvl.level > info.currentLevel); + if (subscribed) { + price.setText(R.string.SettingsCloudManageSubscribed); + } + price.setVisibility(allowSubscribe || subscribed ? View.VISIBLE : View.GONE); + setOnClickListener(v -> { + if (subscribed) { + v.getContext().startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(lvl.manageUrl))); + } else { + new BeamAlertDialogBuilder(getContext()) + .setTitle(lvl.title) + .setMessage(R.string.SettingsCloudManageLevelRedirectMessage) + .setPositiveButton(android.R.string.ok, (dialog, which) -> v.getContext().startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(lvl.subscribeOrUpgradeUrl)))) + .setNegativeButton(R.string.SettingsCloudManageLevelRedirectAlreadySubscribed, (dialog, which) -> v.getContext().startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(features.alreadySubscribedInfoUrl)))) + .show(); + } + }); + setClickable(allowSubscribe || subscribed); + onApplyTheme(); + } + + @Override + public void onApplyTheme() { + int accent = ThemesRepo.getColor(android.R.attr.colorAccent); + if (ColorUtils.calculateLuminance(accent) >= 0.6f) { + accent = ColorUtils.blendARGB(accent, Color.BLACK, 0.075f); + } + boolean tooLight = ColorUtils.calculateLuminance(accent) >= 0.6f; + title.setTextColor(0xffffffff); + price.setTextColor(0xffffffff); + icon.setImageTintList(ColorStateList.valueOf(0xffffffff)); + featuresLayout.setBackground(ViewUtils.createRipple(0, tooLight ? 0x33ffffff : 0x21ffffff, 24)); + setBackground(ViewUtils.createRipple(0x21000000, ColorUtils.blendARGB(0xffffffff, accent, tooLight ? 0.9f : 0.75f), 32)); + } + } + } + private final class AboutItem extends SimpleRecyclerItem { @Override diff --git a/app/src/main/java/ru/ytkab0bp/slicebeam/SliceBeam.java b/app/src/main/java/ru/ytkab0bp/slicebeam/SliceBeam.java index cd271f0..60ac582 100644 --- a/app/src/main/java/ru/ytkab0bp/slicebeam/SliceBeam.java +++ b/app/src/main/java/ru/ytkab0bp/slicebeam/SliceBeam.java @@ -5,6 +5,8 @@ import android.app.Application; import android.content.Intent; import android.util.Log; +import com.instacart.truetime.time.TrueTimeImpl; + import java.io.File; import java.io.FileOutputStream; import java.io.IOException; @@ -26,6 +28,7 @@ import ru.ytkab0bp.slicebeam.boot.PrefsTask; import ru.ytkab0bp.slicebeam.boot.PrintConfigWarmupTask; import ru.ytkab0bp.slicebeam.boot.TrueTimeTask; import ru.ytkab0bp.slicebeam.boot.VibrationUtilsTask; +import ru.ytkab0bp.slicebeam.cloud.CloudController; import ru.ytkab0bp.slicebeam.config.ConfigObject; import ru.ytkab0bp.slicebeam.slic3r.ConfigOptionDef; import ru.ytkab0bp.slicebeam.slic3r.PrintConfigDef; @@ -35,6 +38,7 @@ import ru.ytkab0bp.slicebeam.utils.Prefs; public class SliceBeam extends Application { public static SliceBeam INSTANCE; public static EventBus EVENT_BUS = EventBus.newBus("main"); + public static TrueTimeImpl TRUE_TIME; public static Slic3rConfigWrapper CONFIG; public static int CONFIG_UID = 0; public static BeamServerData SERVER_DATA; @@ -82,6 +86,7 @@ public class SliceBeam extends Application { } catch (Exception e) { Log.e("Config", "Failed to save config", e); } + CloudController.notifyDataChanged(); } public static File getModelCacheDir() { diff --git a/app/src/main/java/ru/ytkab0bp/slicebeam/boot/TrueTimeTask.java b/app/src/main/java/ru/ytkab0bp/slicebeam/boot/TrueTimeTask.java index e63476d..c6fe9ba 100644 --- a/app/src/main/java/ru/ytkab0bp/slicebeam/boot/TrueTimeTask.java +++ b/app/src/main/java/ru/ytkab0bp/slicebeam/boot/TrueTimeTask.java @@ -1,22 +1,72 @@ package ru.ytkab0bp.slicebeam.boot; -import com.instacart.library.truetime.TrueTime; +import androidx.annotation.NonNull; -import java.io.IOException; +import com.instacart.truetime.TrueTimeEventListener; +import com.instacart.truetime.time.TrueTimeImpl; +import com.instacart.truetime.time.TrueTimeParameters; + +import java.net.InetAddress; +import java.util.Date; +import java.util.List; +import java.util.concurrent.CountDownLatch; + +import kotlinx.coroutines.Dispatchers; +import ru.ytkab0bp.slicebeam.SliceBeam; public class TrueTimeTask extends BootTask { public TrueTimeTask() { super(() -> { - for (int i = 0; i < 2; i++) { - try { - TrueTime.build().withNtpHost("1.ru.pool.ntp.org").withConnectionTimeout(300).initialize(); - break; - } catch (IOException ignore) { - try { - Thread.sleep(100); - } catch (InterruptedException ignored) {} + CountDownLatch latch = new CountDownLatch(1); + SliceBeam.TRUE_TIME = new TrueTimeImpl(new TrueTimeParameters.Builder().buildParams(), Dispatchers.getIO(), new TrueTimeEventListener() { + @Override + public void initialize(@NonNull TrueTimeParameters trueTimeParameters) {} + + @Override + public void initializeSuccess(@NonNull long[] longs) { + latch.countDown(); } - } + + @Override + public void initializeFailed(@NonNull Exception e) {} + + @Override + public void nextInitializeIn(long l) {} + + @Override + public void resolvedNtpHostToIPs(@NonNull String s, @NonNull List list) {} + + @Override + public void lastSntpRequestAttempt(@NonNull InetAddress inetAddress) {} + + @Override + public void sntpRequestFailed(@NonNull Exception e) {} + + @Override + public void syncDispatcherException(@NonNull Throwable throwable) {} + + @Override + public void sntpRequest(@NonNull InetAddress inetAddress) {} + + @Override + public void sntpRequestSuccessful(@NonNull InetAddress inetAddress) {} + + @Override + public void sntpRequestFailed(@NonNull InetAddress inetAddress, @NonNull Exception e) {} + + @Override + public void storingTrueTime(@NonNull long[] longs) {} + + @Override + public void returningTrueTime(@NonNull Date date) {} + + @Override + public void returningDeviceTime() {} + }); + SliceBeam.TRUE_TIME.sync(); + try { + latch.await(); + } catch (InterruptedException ignored) {} }); onWorker(); nonCritical = true; diff --git a/app/src/main/java/ru/ytkab0bp/slicebeam/cloud/CloudAPI.java b/app/src/main/java/ru/ytkab0bp/slicebeam/cloud/CloudAPI.java new file mode 100644 index 0000000..2c6cff3 --- /dev/null +++ b/app/src/main/java/ru/ytkab0bp/slicebeam/cloud/CloudAPI.java @@ -0,0 +1,268 @@ +package ru.ytkab0bp.slicebeam.cloud; + +import androidx.annotation.Nullable; + +import com.google.gson.Gson; + +import java.io.InputStream; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import ru.ytkab0bp.sapil.APICallback; +import ru.ytkab0bp.sapil.APILibrary; +import ru.ytkab0bp.sapil.APIRequestHandle; +import ru.ytkab0bp.sapil.APIRunner; +import ru.ytkab0bp.sapil.Arg; +import ru.ytkab0bp.sapil.Header; +import ru.ytkab0bp.sapil.Method; +import ru.ytkab0bp.sapil.RequestType; +import ru.ytkab0bp.slicebeam.BuildConfig; +import ru.ytkab0bp.slicebeam.utils.Prefs; + +public interface CloudAPI extends APIRunner { + CloudAPI INSTANCE = APILibrary.newRunner(CloudAPI.class, new RunnerConfig() { + private final Map headers = new HashMap<>(); + + @Override + public String getBaseURL() { + return "https://api.beam3d.ru/v1/"; + } + + @Override + public String getDefaultUserAgent() { + return "SliceBeam v" + BuildConfig.VERSION_NAME + "/" + BuildConfig.VERSION_CODE; + } + + @Override + public Map getDefaultHeaders() { + headers.clear(); + if (Prefs.getCloudAPIToken() != null) { + headers.put("Authorization", "Bearer " + Prefs.getCloudAPIToken()); + } + return headers; + } + }); + + /** + * Begins login flow, returns auth link + */ + @Method("login/begin") + APIRequestHandle loginBegin(APICallback callback); + + /** + * Checks new login state by session id + */ + @Method("login/check") + void loginCheck(@Arg("sessionId") String sessionId, APICallback callback); + + /** + * Cancels login flow + */ + @Method("login/cancel") + void loginCancel(@Arg("sessionId") String sessionId, APICallback callback); + + /** + * Gets current user info + *

+ * Requires authorization + */ + @Method("user/getInfo") + void userGetInfo(APICallback callback); + + /** + * Gets user features + */ + @Method("user/getFeatures") + void userGetFeatures(APICallback callback); + + /** + * Fetches sync state + *

+ * Requires authorization + */ + @Method("sync/getState") + void syncGetState(APICallback callback); + + /** + * Uploads new data to the server + *

+ * @param data New base64 encoded data + *

+ * Requires authorization + */ + @Method("sync/upload") + void syncUpload(@Arg("data") String data, APICallback callback); + + /** + * Downloads base64 data + *

+ * Requires authorization + */ + @Method("sync/get") + void syncGet(APICallback callback); + + /** + * Generates 3D model from image + *

+ * @param image Base64 encoded image + *

+ * Requires authorization + */ + @Method(requestType = RequestType.POST, value = "models/generate") + void modelsGenerate(@Arg("") String image, @Header("Content-Type") String type, APICallback callback); + + /** + * Gets remaining model generations count + *

+ * Requires authorization + */ + @Method("models/getRemainingCount") + void modelsGetRemainingCount(APICallback callback); + + /** + * Destroys token + *

+ * Requires authorization + */ + @Method("logout") + void logout(APICallback callback); + + final class LoginData { + /** + * Url that should be clicked by the user to authorize + */ + public String url; + + /** + * Session identifier + */ + public String sessionId; + + /** + * Time at which session should be considered expired if not logged in + */ + public long expiresAt; + } + + final class LoginState { + /** + * If user is now logged in + */ + public boolean loggedIn; + + /** + * Bearer token if auth was successful + */ + public String bearer; + } + + final class UserFeatures { + /** + * Which level is required for data sync + */ + public int syncRequiredLevel; + + /** + * Which level is required for AI model generator + */ + public int aiGeneratorRequiredLevel; + + /** + * Models per month max + */ + public int aiGeneratorModelsPerMonth; + + /** + * Url at which user should be redirected for info about how to restore a subscription + */ + public String alreadySubscribedInfoUrl; + + /** + * List of subscription levels + */ + public List levels = new ArrayList<>(); + } + + final class SubscriptionLevel { + /** + * Int representation + */ + public int level; + + /** + * Title of this level + */ + public String title; + + /** + * Price of this level + */ + public String price; + + /** + * Url at which user should be redirected for purchase + */ + public String subscribeOrUpgradeUrl; + + /** + * Url at which user should be redirected for managing the subscription + */ + public String manageUrl; + } + + + final class UserInfo { + /** + * User's id + */ + public String id; + + /** + * User's display name + */ + public String displayName; + + /** + * User's avatar. Could be null + */ + @Nullable + public String avatarUrl; + + /** + * Current subscription level + */ + public int currentLevel; + } + + + final class SyncState { + /** + * Cloud data last updated time + */ + public long lastUpdatedDate = 0; + + /** + * Used size of cloud storage + */ + public long usedSize; + + /** + * Max storage size + */ + public long maxSize; + } + + final class ModelsRemainingCount { + /** + * Used generations + */ + public int used; + + /** + * Max available generations + */ + public int max; + } +} diff --git a/app/src/main/java/ru/ytkab0bp/slicebeam/cloud/CloudController.java b/app/src/main/java/ru/ytkab0bp/slicebeam/cloud/CloudController.java new file mode 100644 index 0000000..2551811 --- /dev/null +++ b/app/src/main/java/ru/ytkab0bp/slicebeam/cloud/CloudController.java @@ -0,0 +1,299 @@ +package ru.ytkab0bp.slicebeam.cloud; + +import android.content.Intent; +import android.net.Uri; +import android.util.Log; + +import com.google.gson.Gson; + +import ru.ytkab0bp.sapil.APICallback; +import ru.ytkab0bp.sapil.APIRequestHandle; +import ru.ytkab0bp.slicebeam.R; +import ru.ytkab0bp.slicebeam.SliceBeam; +import ru.ytkab0bp.slicebeam.events.CloudFeaturesUpdatedEvent; +import ru.ytkab0bp.slicebeam.events.CloudLoginStateUpdatedEvent; +import ru.ytkab0bp.slicebeam.events.CloudModelsRemainingCountUpdatedEvent; +import ru.ytkab0bp.slicebeam.events.CloudUserInfoUpdatedEvent; +import ru.ytkab0bp.slicebeam.events.NeedDismissSnackbarEvent; +import ru.ytkab0bp.slicebeam.events.NeedSnackbarEvent; +import ru.ytkab0bp.slicebeam.utils.Prefs; +import ru.ytkab0bp.slicebeam.utils.ViewUtils; +import ru.ytkab0bp.slicebeam.view.SnackbarsLayout; + +public class CloudController { + public final static String USER_INFO_AI_GEN_TAG = "ai_gen_user_info"; + private final static String CLOUD_SYNC_TAG = "cloud_sync"; + + private final static String TAG = "cloud"; + private final static long MIN_SYNC_DELTA = 5 * 60 * 1000L; // Once in 5 minutes + private final static long MIN_SYNC_FEATURES_DELTA = 12 * 60 * 60 * 1000L; // Once in 12 hours + + private static boolean isSyncInProgress; + private static CloudAPI.UserInfo userInfo; + private static CloudAPI.UserFeatures userFeatures; + + private static int modelsUsed; + private static int modelsMaxGenerations; + private static boolean isLoggingIn; + private static APIRequestHandle beginLoginHandle; + private static String loginSessionId; + private static Runnable loginAutoCancel = () -> { + loginSessionId = null; + isLoggingIn = false; + SliceBeam.EVENT_BUS.fireEvent(new CloudLoginStateUpdatedEvent()); + }; + private static Runnable loginCheck = new Runnable() { + @Override + public void run() { + CloudAPI.INSTANCE.loginCheck(loginSessionId, new APICallback() { + @Override + public void onResponse(CloudAPI.LoginState response) { + if (response.loggedIn) { + Prefs.setCloudAPIToken(response.bearer); + loadUserInfo(); + ViewUtils.removeCallbacks(loginAutoCancel); + } else if (isLoggingIn) { + ViewUtils.postOnMainThread(loginCheck, 5000); + } + } + + @Override + public void onException(Exception e) { + Log.e(TAG, "Failed to check login state", e); + + if (isLoggingIn) { + ViewUtils.postOnMainThread(loginCheck, 5000); + } + } + }); + } + }; + + private static Gson gson = new Gson(); + + public static void init() { + if (Prefs.getCloudCachedUserFeatures() != null) { + userFeatures = gson.fromJson(Prefs.getCloudCachedUserFeatures(), CloudAPI.UserFeatures.class); + SliceBeam.EVENT_BUS.fireEvent(new CloudFeaturesUpdatedEvent()); + } + long now = SliceBeam.TRUE_TIME.now().getTime(); + boolean needSyncInfo = userFeatures == null || now - Prefs.getCloudLastFeaturesSync() > MIN_SYNC_FEATURES_DELTA; + if (needSyncInfo) { + checkUserFeatures(); + } + + if (Prefs.getCloudAPIToken() != null) { + if (Prefs.getCloudCachedUserInfo() != null) { + userInfo = gson.fromJson(Prefs.getCloudCachedUserInfo(), CloudAPI.UserInfo.class); + modelsUsed = Prefs.getCloudCachedUsedModels(); + modelsMaxGenerations = Prefs.getCloudCachedMaxModels(); + } + + if (needSyncInfo || userInfo == null) { + loadUserInfo(); + } + } + } + + private static void loadUserInfo() { + CloudAPI.INSTANCE.userGetInfo(new APICallback() { + @Override + public void onResponse(CloudAPI.UserInfo response) { + userInfo = response; + + if (userInfo.id.equals("null")) { + userInfo = null; + Prefs.setCloudAPIToken(null); + Prefs.setCloudCachedUserInfo(null); + SliceBeam.EVENT_BUS.fireEvent(new CloudUserInfoUpdatedEvent()); + + if (isLoggingIn) { + isLoggingIn = false; + SliceBeam.EVENT_BUS.fireEvent(new CloudLoginStateUpdatedEvent()); + } + } else { + Prefs.setCloudCachedUserInfo(gson.toJson(userInfo)); + + SliceBeam.EVENT_BUS.fireEvent(new NeedDismissSnackbarEvent(USER_INFO_AI_GEN_TAG)); + SliceBeam.EVENT_BUS.fireEvent(new CloudUserInfoUpdatedEvent()); + + if (isLoggingIn) { + isLoggingIn = false; + SliceBeam.EVENT_BUS.fireEvent(new CloudLoginStateUpdatedEvent()); + } + + if (isSyncAvailable() && Prefs.isCloudProfileSyncEnabled()) { + long now = SliceBeam.TRUE_TIME.now().getTime(); + if (now != Prefs.getLocalLastModified()) { + sendData(); + } + } + checkGeneratorRemaining(); + } + Prefs.setCloudLastFeaturesSync(SliceBeam.TRUE_TIME.now().getTime()); + } + + @Override + public void onException(Exception e) { + Log.e(TAG, "Failed to get user info", e); + ViewUtils.postOnMainThread(CloudController::init, 15000); + } + }); + } + + public static boolean isLoggingIn() { + return isLoggingIn; + } + + private static void beginLogin0() { + beginLoginHandle = CloudAPI.INSTANCE.loginBegin(new APICallback() { + @Override + public void onResponse(CloudAPI.LoginData response) { + loginSessionId = response.sessionId; + + ViewUtils.postOnMainThread(loginAutoCancel, response.expiresAt * 1000L - SliceBeam.TRUE_TIME.now().getTime()); + ViewUtils.postOnMainThread(loginCheck, 5000); + ViewUtils.postOnMainThread(() -> SliceBeam.INSTANCE.startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(response.url)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))); + } + + @Override + public void onException(Exception e) { + ViewUtils.postOnMainThread(CloudController::beginLogin0, 15000); + } + }); + } + + public static void beginLogin() { + isLoggingIn = true; + SliceBeam.EVENT_BUS.fireEvent(new CloudLoginStateUpdatedEvent()); + beginLogin0(); + } + + public static void cancelLogin() { + isLoggingIn = false; + SliceBeam.EVENT_BUS.fireEvent(new CloudLoginStateUpdatedEvent()); + if (loginSessionId != null) { + CloudAPI.INSTANCE.loginCancel(loginSessionId, response -> {}); + } + if (beginLoginHandle != null && beginLoginHandle.isRunning()) { + beginLoginHandle.cancel(); + beginLoginHandle = null; + } + ViewUtils.removeCallbacks(loginCheck); + ViewUtils.removeCallbacks(loginAutoCancel); + loginSessionId = null; + } + + public static void logout() { + Prefs.setCloudAPIToken(null); + userInfo = null; + SliceBeam.EVENT_BUS.fireEvent(new CloudLoginStateUpdatedEvent()); + SliceBeam.EVENT_BUS.fireEvent(new CloudUserInfoUpdatedEvent()); + CloudAPI.INSTANCE.logout(response -> {}); + } + + public static void checkGeneratorRemaining() { + CloudAPI.INSTANCE.modelsGetRemainingCount(new APICallback() { + @Override + public void onResponse(CloudAPI.ModelsRemainingCount response) { + modelsUsed = response.used; + modelsMaxGenerations = response.max; + Prefs.setCloudCachedUsedMaxModels(modelsUsed, modelsMaxGenerations); + SliceBeam.EVENT_BUS.fireEvent(new CloudModelsRemainingCountUpdatedEvent()); + } + + @Override + public void onException(Exception e) { + Log.e(TAG, "Failed to check remaining models", e); + ViewUtils.postOnMainThread(CloudController::checkGeneratorRemaining, 15000); + } + }); + } + + public static void checkUserFeatures() { + CloudAPI.INSTANCE.userGetFeatures(new APICallback() { + @Override + public void onResponse(CloudAPI.UserFeatures response) { + userFeatures = response; + Prefs.setCloudCachedUserFeatures(gson.toJson(userFeatures)); + if (Prefs.getCloudAPIToken() == null) { + Prefs.setCloudLastFeaturesSync(SliceBeam.TRUE_TIME.now().getTime()); + } + SliceBeam.EVENT_BUS.fireEvent(new CloudFeaturesUpdatedEvent()); + } + + @Override + public void onException(Exception e) { + Log.e(TAG, "Failed to get user features", e); + ViewUtils.postOnMainThread(CloudController::checkUserFeatures, 15000); + } + }); + } + + public static CloudAPI.UserInfo getUserInfo() { + return userInfo; + } + + public static CloudAPI.UserFeatures getUserFeatures() { + return userFeatures; + } + + public static boolean isSyncAvailable() { + return Prefs.getCloudAPIToken() != null && userInfo != null && userFeatures != null && userInfo.currentLevel >= userFeatures.syncRequiredLevel; + } + + public static boolean needShowAIGenerator() { + return userFeatures != null && userFeatures.aiGeneratorRequiredLevel >= 0; + } + + public static int getGeneratedModels() { + return modelsUsed; + } + + public static int getMaxGeneratedModels() { + return modelsMaxGenerations; + } + + private static void sendData() { + if (isSyncInProgress) { + return; + } + // TODO: IMPORTANT: Check getState first, then show conflict info + long modified = Prefs.getLocalLastModified(); + isSyncInProgress = true; + SliceBeam.EVENT_BUS.fireEvent(new NeedSnackbarEvent(SnackbarsLayout.Type.LOADING, R.string.CloudSyncInProgress).tag(CLOUD_SYNC_TAG)); + CloudAPI.INSTANCE.syncUpload("", new APICallback() { + @Override + public void onResponse(CloudAPI.SyncState response) { + isSyncInProgress = false; + if (Prefs.getLocalLastModified() != modified) { // Re-send otherwise + sendData(); + return; + } + Prefs.setCloudLastSync(modified); + SliceBeam.EVENT_BUS.fireEvent(new NeedDismissSnackbarEvent(CLOUD_SYNC_TAG)); + SliceBeam.EVENT_BUS.fireEvent(new NeedSnackbarEvent(R.string.CloudSyncSuccess)); + } + + @Override + public void onException(Exception e) { + Log.e(TAG, "Failed to upload sync data", e); + isSyncInProgress = false; + + SliceBeam.EVENT_BUS.fireEvent(new NeedDismissSnackbarEvent(CLOUD_SYNC_TAG)); + SliceBeam.EVENT_BUS.fireEvent(new NeedSnackbarEvent(SnackbarsLayout.Type.ERROR, R.string.CloudSyncError)); + } + }); + } + + public static void notifyDataChanged() { + long now = SliceBeam.TRUE_TIME.now().getTime(); + Prefs.setLocalLastModified(now); + if (!isSyncAvailable() || !Prefs.isCloudProfileSyncEnabled()) { + return; + } + if (now - Prefs.getCloudLastSync() > MIN_SYNC_DELTA) { + sendData(); + } + } +} diff --git a/app/src/main/java/ru/ytkab0bp/slicebeam/components/CloudManageBottomSheet.java b/app/src/main/java/ru/ytkab0bp/slicebeam/components/CloudManageBottomSheet.java new file mode 100644 index 0000000..c535518 --- /dev/null +++ b/app/src/main/java/ru/ytkab0bp/slicebeam/components/CloudManageBottomSheet.java @@ -0,0 +1,154 @@ +package ru.ytkab0bp.slicebeam.components; + +import android.content.Context; +import android.content.Intent; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.GradientDrawable; +import android.net.Uri; +import android.text.SpannableStringBuilder; +import android.util.TypedValue; +import android.view.Gravity; +import android.view.ViewGroup; +import android.widget.LinearLayout; +import android.widget.Space; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.core.content.ContextCompat; +import androidx.core.graphics.ColorUtils; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.google.android.material.bottomsheet.BottomSheetDialog; + +import java.util.ArrayList; +import java.util.List; + +import ru.ytkab0bp.slicebeam.R; +import ru.ytkab0bp.slicebeam.cloud.CloudAPI; +import ru.ytkab0bp.slicebeam.cloud.CloudController; +import ru.ytkab0bp.slicebeam.recycler.PreferenceSwitchItem; +import ru.ytkab0bp.slicebeam.recycler.SimpleRecyclerAdapter; +import ru.ytkab0bp.slicebeam.recycler.SimpleRecyclerItem; +import ru.ytkab0bp.slicebeam.theme.ThemesRepo; +import ru.ytkab0bp.slicebeam.utils.Prefs; +import ru.ytkab0bp.slicebeam.utils.ViewUtils; +import ru.ytkab0bp.slicebeam.view.TextColorImageSpan; + +public class CloudManageBottomSheet extends BottomSheetDialog { + public CloudManageBottomSheet(@NonNull Context context) { + super(context); + + LinearLayout ll = new LinearLayout(context); + ll.setOrientation(LinearLayout.VERTICAL); + GradientDrawable gd = new GradientDrawable(); + gd.setCornerRadii(new float[] { + ViewUtils.dp(28), ViewUtils.dp(28), + ViewUtils.dp(28), ViewUtils.dp(28), + 0, 0, + 0, 0 + }); + gd.setColor(ThemesRepo.getColor(R.attr.dialogBackground)); + ll.setBackground(gd); + ll.setPadding(0, ViewUtils.dp(12), 0, ViewUtils.dp(12)); + + TextView title = new TextView(context); + title.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 20); + title.setTypeface(ViewUtils.getTypeface(ViewUtils.ROBOTO_MEDIUM)); + title.setText(R.string.SettingsCloudManageButtonManage); + title.setTextColor(ThemesRepo.getColor(android.R.attr.textColorPrimary)); + title.setGravity(Gravity.CENTER); + title.setLayoutParams(new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) {{ + leftMargin = rightMargin = ViewUtils.dp(21); + }}); + ll.addView(title); + + TextView description = new TextView(context); + description.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 16); + description.setText(context.getString(R.string.SettingsCloudManageLoggedInAs, CloudController.getUserInfo().displayName)); + description.setTextColor(ThemesRepo.getColor(android.R.attr.textColorSecondary)); + description.setGravity(Gravity.CENTER); + description.setLayoutParams(new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) {{ + leftMargin = rightMargin = ViewUtils.dp(21); + topMargin = ViewUtils.dp(8); + }}); + ll.addView(description); + + int currentLevel = CloudController.getUserInfo().currentLevel; + CloudAPI.SubscriptionLevel lvl = null; + CloudAPI.UserFeatures features = CloudController.getUserFeatures(); + for (CloudAPI.SubscriptionLevel level : features.levels) { + if (level.level != -1 && level.level <= currentLevel && (lvl == null || level.level > lvl.level)) { + lvl = level; + } + } + + if (lvl != null) { + List items = new ArrayList<>(); + if (currentLevel >= features.syncRequiredLevel) { + items.add(new PreferenceSwitchItem() + .setIcon(R.drawable.sync_outline_28) + .setTitle(context.getString(R.string.SettingsCloudManageFeatureCloudSync)) + .setValueProvider(Prefs::isCloudProfileSyncEnabled) + .setChangeListener((buttonView, isChecked) -> { + Prefs.setCloudProfileSyncEnabled(isChecked); + if (isChecked) { + CloudController.notifyDataChanged(); + } + })); + } + if (!items.isEmpty()) { + RecyclerView recyclerView = new RecyclerView(context); + recyclerView.setLayoutManager(new LinearLayoutManager(context)); + recyclerView.setBackground(ViewUtils.createRipple(0, ColorUtils.setAlphaComponent(ThemesRepo.getColor(android.R.attr.colorControlHighlight), 0x10), 16)); + SimpleRecyclerAdapter adapter = new SimpleRecyclerAdapter(); + adapter.setItems(items); + recyclerView.setAdapter(adapter); + ll.addView(recyclerView, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) {{ + topMargin = ViewUtils.dp(12); + leftMargin = rightMargin = ViewUtils.dp(12); + }}); + } + + TextView manageButton = new TextView(context); + SpannableStringBuilder sb = SpannableStringBuilder.valueOf(context.getString(R.string.SettingsCloudManageSubscription)).append(" "); + Drawable dr = ContextCompat.getDrawable(context, R.drawable.external_link_outline_24); + int size = ViewUtils.dp(16); + dr.setBounds(0, 0, size, size); + sb.append("d", new TextColorImageSpan(dr, ViewUtils.dp(2f)), SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE); + manageButton.setText(sb); + manageButton.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 15); + manageButton.setTextColor(ThemesRepo.getColor(android.R.attr.textColorSecondary)); + manageButton.setTypeface(ViewUtils.getTypeface(ViewUtils.ROBOTO_MEDIUM)); + manageButton.setGravity(Gravity.CENTER); + manageButton.setPadding(ViewUtils.dp(12), ViewUtils.dp(8), ViewUtils.dp(12), ViewUtils.dp(8)); + manageButton.setBackground(ViewUtils.createRipple(ThemesRepo.getColor(android.R.attr.colorControlHighlight), 16)); + CloudAPI.SubscriptionLevel finalLvl = lvl; + manageButton.setOnClickListener(v -> v.getContext().startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(finalLvl.manageUrl)))); + ll.addView(manageButton, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewUtils.dp(48)) {{ + leftMargin = rightMargin = ViewUtils.dp(16); + topMargin = bottomMargin = ViewUtils.dp(6); + }}); + } else { + ll.addView(new Space(context), new LinearLayout.LayoutParams(0, ViewUtils.dp(16))); + } + + TextView buttonView = new TextView(context); + buttonView.setText(R.string.SettingsCloudManageButtonLogOut); + buttonView.setTextColor(ThemesRepo.getColor(R.attr.textColorOnAccent)); + buttonView.setTypeface(ViewUtils.getTypeface(ViewUtils.ROBOTO_MEDIUM)); + buttonView.setGravity(Gravity.CENTER); + buttonView.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 16); + buttonView.setBackground(ViewUtils.createRipple(ThemesRepo.getColor(android.R.attr.colorControlHighlight), ThemesRepo.getColor(R.attr.textColorNegative), 16)); + buttonView.setOnClickListener(v-> { + CloudController.logout(); + dismiss(); + }); + ll.addView(buttonView, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewUtils.dp(52)) {{ + leftMargin = rightMargin = ViewUtils.dp(16); + bottomMargin = ViewUtils.dp(4); + }}); + + setContentView(ll); + } +} diff --git a/app/src/main/java/ru/ytkab0bp/slicebeam/components/bed_menu/BedMenuItem.java b/app/src/main/java/ru/ytkab0bp/slicebeam/components/bed_menu/BedMenuItem.java index 9be4d9b..63ad83f 100644 --- a/app/src/main/java/ru/ytkab0bp/slicebeam/components/bed_menu/BedMenuItem.java +++ b/app/src/main/java/ru/ytkab0bp/slicebeam/components/bed_menu/BedMenuItem.java @@ -5,6 +5,10 @@ import android.content.res.ColorStateList; import android.graphics.Canvas; import android.graphics.Paint; import android.graphics.Path; +import android.graphics.PointF; +import android.graphics.PorterDuff; +import android.graphics.PorterDuffColorFilter; +import android.graphics.drawable.Drawable; import android.text.TextUtils; import android.util.TypedValue; import android.view.Gravity; @@ -16,16 +20,23 @@ import android.widget.LinearLayout; import android.widget.TextView; import androidx.annotation.NonNull; +import androidx.core.content.ContextCompat; import androidx.core.graphics.ColorUtils; import androidx.dynamicanimation.animation.FloatValueHolder; import androidx.dynamicanimation.animation.SpringAnimation; import androidx.dynamicanimation.animation.SpringForce; import androidx.recyclerview.widget.RecyclerView; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; + import ru.ytkab0bp.slicebeam.R; +import ru.ytkab0bp.slicebeam.SliceBeam; import ru.ytkab0bp.slicebeam.recycler.SimpleRecyclerItem; import ru.ytkab0bp.slicebeam.theme.IThemeView; import ru.ytkab0bp.slicebeam.theme.ThemesRepo; +import ru.ytkab0bp.slicebeam.utils.RandomUtils; import ru.ytkab0bp.slicebeam.utils.ViewUtils; public class BedMenuItem extends SimpleRecyclerItem { @@ -36,6 +47,7 @@ public class BedMenuItem extends SimpleRecyclerItem sparkles; + private long lastDraw; + private Drawable sparkleDrawable; public BedMenuItemHolderView(Context context) { super(context); @@ -109,12 +134,17 @@ public class BedMenuItem extends SimpleRecyclerItem(); + if (sparkleDrawable == null) { + sparkleDrawable = ContextCompat.getDrawable(SliceBeam.INSTANCE, R.drawable.sparkle_28); + sparkleDrawable.setColorFilter(new PorterDuffColorFilter(ThemesRepo.getColor(android.R.attr.colorAccent), PorterDuff.Mode.SRC_IN)); + } + + float p = dt / 1000f; + for (Iterator iterator = sparkles.iterator(); iterator.hasNext(); ) { + Sparkle sparkle = iterator.next(); + sparkle.position.x += sparkle.velocity.x * p; + sparkle.position.y += sparkle.velocity.y * p; + sparkle.velocity.x *= 0.9999f; + sparkle.velocity.y *= 0.9999f; + sparkle.living += dt; + + int size = (int) (side * sparkle.size); + + float fadems = 200; + if ((sparkle.position.x - sparkle.size > 0 && sparkle.position.x + sparkle.size < 1f) && + sparkle.lifetime - sparkle.living > fadems) { + sparkle.living = (long) (sparkle.lifetime - fadems); + } + if (sparkle.living >= sparkle.lifetime) { + iterator.remove(); + } else { + float alpha = sparkle.living < fadems ? sparkle.living / fadems : sparkle.living > sparkle.lifetime - fadems ? (sparkle.lifetime - sparkle.living) / fadems : 1f; + canvas.saveLayerAlpha(-OUT_BOUND * side, -OUT_BOUND * side, getWidth() + OUT_BOUND * side, getHeight() + OUT_BOUND * side, (int) (alpha * sparkle.alpha * 0xFF)); + canvas.translate(sparkle.position.x * side, sparkle.position.y * side); + sparkleDrawable.setBounds(-size / 2, -size / 2, size / 2, size / 2); + sparkleDrawable.draw(canvas); + canvas.restore(); + } + } + if (sparkles.size() < 20) { + int s = 20 - sparkles.size(); + for (int i = 0; i < s; i++) { + if (RandomUtils.RANDOM.nextFloat() < 0.01f) { + Sparkle sparkle = new Sparkle(); + boolean leftSide = RandomUtils.RANDOM.nextBoolean(); + sparkle.position = new PointF(leftSide ? RandomUtils.randomf(-OUT_BOUND, 0) : RandomUtils.randomf(1, 1 + OUT_BOUND), RandomUtils.randomf(-OUT_BOUND, 1 + OUT_BOUND)); + sparkle.velocity = new PointF(RandomUtils.randomf(-0.05f, 0.05f), RandomUtils.randomf(-0.05f, 0.05f)); + sparkle.size = RandomUtils.randomf(0.1f, 0.12f); + sparkle.alpha = RandomUtils.randomf(0.5f, 1f); + sparkle.lifetime = RandomUtils.randoml(4000, 10000); + sparkles.add(sparkle); + } + } + } + invalidate(); + canvas.restore(); + } } @Override @@ -146,10 +231,12 @@ public class BedMenuItem extends SimpleRecyclerItem K3D_SUPPORTED_LANGUAGES = Arrays.asList("en", "ru"); + private boolean wasPortrait; + private String getK3DLanguage() { String lang = Locale.getDefault().getLanguage(); return K3D_SUPPORTED_LANGUAGES.contains(lang) ? lang : "en"; @@ -74,13 +90,18 @@ public class FileMenu extends ListBedMenu { .replace("\"", "\\\""); } + private boolean hasModel() { + return fragment.getGlView().getRenderer().getModel() != null; + } + private boolean hasSelection() { - return fragment.getGlView().getRenderer().getModel() != null && fragment.getGlView().getRenderer().getSelectedObject() != -1; + return hasModel() && fragment.getGlView().getRenderer().getSelectedObject() != -1; } @Override protected List onCreateItems(boolean portrait) { - return Arrays.asList( + wasPortrait = portrait; + List list = new ArrayList<>(Arrays.asList( new BedMenuItem(R.string.MenuFileOpen, R.drawable.folder_simple_plus_outline_28).onClick(v -> { if (!fragment.getGlView().getRenderer().getBed().isValid()) { Toast.makeText(fragment.getContext(), R.string.BedConfigurationError, Toast.LENGTH_SHORT).show(); @@ -104,7 +125,31 @@ public class FileMenu extends ListBedMenu { fragment.updateModel(); } }), - new SpaceItem(portrait ? ViewUtils.dp(3) : 0, portrait ? 0 : ViewUtils.dp(3)), + new SpaceItem(portrait ? ViewUtils.dp(3) : 0, portrait ? 0 : ViewUtils.dp(3)))); + if (BeamServerData.isBoostyAvailable() && CloudController.needShowAIGenerator()) { + list.add(new BedMenuItem(R.string.MenuFileAIGenerator, R.drawable.picture_stack_outline_28).setShiny(true).onClick(view -> { + if (Prefs.getCloudAPIToken() == null || CloudController.getUserInfo() != null && CloudController.getMaxGeneratedModels() == 0) { + Context ctx = view.getContext(); + ctx.startActivity(new Intent(ctx, SetupActivity.class).putExtra(SetupActivity.EXTRA_CLOUD_PROFILE, true)); + return; + } + if (CloudController.getUserInfo() == null) { + SliceBeam.EVENT_BUS.fireEvent(new NeedSnackbarEvent(SnackbarsLayout.Type.LOADING, R.string.MenuFileAIGeneratorPleaseWaitSetup).tag(CloudController.USER_INFO_AI_GEN_TAG)); + ViewUtils.postOnMainThread(() -> { + if (CloudController.getUserInfo() == null) { + SliceBeam.EVENT_BUS.fireEvent(new NeedDismissSnackbarEvent(CloudController.USER_INFO_AI_GEN_TAG)); + SliceBeam.EVENT_BUS.fireEvent(new NeedSnackbarEvent(SnackbarsLayout.Type.ERROR, R.string.MenuFileAIGeneratorErrorNotLoadedUserAccount)); + } else { + fragment.showUnfoldMenu(new AIGeneratorMenu(), view); + } + }, 2500); + return; + } + + fragment.showUnfoldMenu(new AIGeneratorMenu(), view); + })); + } + list.addAll(Arrays.asList( new BedMenuItem(R.string.MenuFileCalibrations, R.drawable.wrench_outline_28).setSingleLine(true).onClick(v -> { if (!fragment.getGlView().getRenderer().getBed().isValid()) { Toast.makeText(fragment.getContext(), R.string.BedConfigurationError, Toast.LENGTH_SHORT).show(); @@ -200,14 +245,28 @@ public class FileMenu extends ListBedMenu { .show()) .setNegativeButton(android.R.string.cancel, null) .show(); + }), + new BedMenuItem(R.string.MenuFileExport3mf, R.drawable.arrow_down_to_square_outline_28).setEnabled(hasModel()).onClick(v -> { + if (fragment.getContext() instanceof Activity) { + Activity act = (Activity) fragment.getContext(); + Intent i = new Intent(Intent.ACTION_CREATE_DOCUMENT); + i.setType("application/3mf"); + i.putExtra(Intent.EXTRA_TITLE, "SliceBeam_project.3mf"); + act.startActivityForResult(i, MainActivity.REQUEST_CODE_EXPORT_3MF); + } }) - ); + )); + return list; } @EventHandler(runOnMainThread = true) public void onObjectsChanged(ObjectsListChangedEvent e) { ((BedMenuItem) adapter.getItems().get(1)).setEnabled(hasSelection()); adapter.notifyItemChanged(1); + + int i = 8 - (BeamServerData.isBoostyAvailable() && CloudController.needShowAIGenerator() ? 0 : 1); + ((BedMenuItem) adapter.getItems().get(i)).setEnabled(hasModel()); + adapter.notifyItemChanged(i); } @EventHandler(runOnMainThread = true) @@ -216,8 +275,144 @@ public class FileMenu extends ListBedMenu { adapter.notifyItemChanged(1); } - public final class CalibrationsMenu extends UnfoldMenu { + @EventHandler(runOnMainThread = true) + public void onFeaturedUpdated(CloudFeaturesUpdatedEvent e) { + adapter.setItems(onCreateItems(wasPortrait)); + } + public final static class AIGeneratorMenu extends UnfoldMenu { + private TextView remainingView; + private SegmentsView segmentsView; + + @Override + public int getRequestedSize(FrameLayout into, boolean portrait) { + return (int) (portrait ? ViewUtils.dp(52) + ViewUtils.dp(60) * 2 + ViewUtils.dp(28) + ViewUtils.dp(18) + ViewUtils.dp(2) : into.getWidth() * 0.6f); + } + + @Override + protected View onCreateView(Context ctx, boolean portrait) { + LinearLayout ll = new LinearLayout(ctx); + ll.setOrientation(LinearLayout.VERTICAL); + + RecyclerView rv = new FadeRecyclerView(ctx); + rv.setOverScrollMode(View.OVER_SCROLL_NEVER); + SimpleRecyclerAdapter adapter = new SimpleRecyclerAdapter(); + adapter.setItems(Arrays.asList( + new PreferenceItem().setIcon(R.drawable.camera_outline_28).setTitle(ctx.getString(R.string.MenuFileAIGeneratorFromCamera)).setOnClickListener(v -> { + if (CloudController.getGeneratedModels() >= CloudController.getMaxGeneratedModels()) { + SliceBeam.EVENT_BUS.fireEvent(new NeedSnackbarEvent(SnackbarsLayout.Type.ERROR, R.string.MenuFileAIGeneratorNoGenerationsLeft)); + return; + } + if (ctx instanceof MainActivity) { + try { + MainActivity.aiTempFile = File.createTempFile("ai_capture", ".jpg"); + Intent i = new Intent(MediaStore.ACTION_IMAGE_CAPTURE); + i.putExtra(MediaStore.EXTRA_OUTPUT, FileProvider.getUriForFile(ctx, BuildConfig.APPLICATION_ID + ".provider", MainActivity.aiTempFile)); + i.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION); + ((MainActivity) ctx).startActivityForResult(i, MainActivity.REQUEST_CODE_AI_GENERATOR_TAKE_PHOTO); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + }), + new PreferenceItem().setIcon(R.drawable.picture_outline_28).setTitle(ctx.getString(R.string.MenuFileAIGeneratorFromGallery)).setOnClickListener(v -> { + if (CloudController.getGeneratedModels() >= CloudController.getMaxGeneratedModels()) { + SliceBeam.EVENT_BUS.fireEvent(new NeedSnackbarEvent(SnackbarsLayout.Type.ERROR, R.string.MenuFileAIGeneratorNoGenerationsLeft)); + return; + } + if (ctx instanceof MainActivity) { + Intent intent = new Intent(); + intent.setType("image/*"); + intent.setAction(Intent.ACTION_GET_CONTENT); + ((MainActivity) ctx).startActivityForResult(Intent.createChooser(intent, ""), MainActivity.REQUEST_CODE_AI_GENERATOR_CHOOSE_PHOTO); + } + }) + )); + rv.setAdapter(adapter); + ll.addView(rv, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)); + + ll.addView(new DividerView(ctx), new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewUtils.dp(1f))); + + remainingView = new TextView(ctx); + remainingView.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 13); + remainingView.setGravity(Gravity.CENTER); + remainingView.setTextColor(ThemesRepo.getColor(android.R.attr.textColorSecondary)); + ll.addView(remainingView, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewUtils.dp(18)) {{ + topMargin = ViewUtils.dp(8); + }}); + + segmentsView = new SegmentsView(ctx) { + @Override + protected int onGetColor(int i) { + return i == 1 ? ThemesRepo.getColor(android.R.attr.textColorSecondary) : ThemesRepo.getColor(android.R.attr.colorAccent); + } + }; + ll.addView(segmentsView, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewUtils.dp(12)) {{ + leftMargin = rightMargin = ViewUtils.dp(12); + topMargin = bottomMargin = ViewUtils.dp(8); + }}); + updateRemaining(); + + ll.addView(new DividerView(ctx), new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewUtils.dp(1f))); + + LinearLayout toolbar = new LinearLayout(ctx); + toolbar.setPadding(ViewUtils.dp(12), 0, ViewUtils.dp(12), 0); + toolbar.setOrientation(LinearLayout.HORIZONTAL); + toolbar.setGravity(Gravity.CENTER_VERTICAL); + toolbar.setBackground(ViewUtils.createRipple(ThemesRepo.getColor(android.R.attr.colorControlHighlight), 0)); + toolbar.setOnClickListener(v -> dismiss()); + + ImageView icon = new ImageView(ctx); + icon.setImageResource(R.drawable.arrow_left_outline_28); + icon.setColorFilter(ThemesRepo.getColor(android.R.attr.textColorSecondary)); + toolbar.addView(icon, new LinearLayout.LayoutParams(ViewUtils.dp(28), ViewUtils.dp(28))); + + TextView title = new TextView(ctx); + title.setText(R.string.MenuOrientationPositionBack); + title.setTypeface(ViewUtils.getTypeface(ViewUtils.ROBOTO_MEDIUM)); + title.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 18); + title.setTextColor(ThemesRepo.getColor(android.R.attr.textColorPrimary)); + toolbar.addView(title, new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f) {{ + leftMargin = ViewUtils.dp(12); + }}); + ll.addView(toolbar, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewUtils.dp(52))); + return ll; + } + + @Override + protected void onCreate() { + super.onCreate(); + + SliceBeam.EVENT_BUS.registerListener(this); + ViewUtils.postOnMainThread(() -> segmentsView.startAnimation(), 50); + } + + @EventHandler(runOnMainThread = true) + public void onDismiss(NeedDismissAIGeneratorMenu e) { + dismiss(); + } + + @EventHandler(runOnMainThread = true) + public void onRemainingUpdated(CloudModelsRemainingCountUpdatedEvent e) { + updateRemaining(); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + + SliceBeam.EVENT_BUS.unregisterListener(this); + } + + private void updateRemaining() { + int rev = CloudController.getMaxGeneratedModels() - CloudController.getGeneratedModels(); + remainingView.setText(SliceBeam.INSTANCE.getString(R.string.MenuFileAIGeneratorRemaining, rev, CloudController.getMaxGeneratedModels())); + segmentsView.setValues(new float[]{0, rev / (float) CloudController.getMaxGeneratedModels(), 1}); + } + } + + public final class CalibrationsMenu extends UnfoldMenu { + @Override public int getRequestedSize(FrameLayout into, boolean portrait) { return (int) (portrait ? into.getHeight() * 0.35f : into.getWidth() * 0.6f); } diff --git a/app/src/main/java/ru/ytkab0bp/slicebeam/components/bed_menu/ListBedMenu.java b/app/src/main/java/ru/ytkab0bp/slicebeam/components/bed_menu/ListBedMenu.java index a380342..7f46a5f 100644 --- a/app/src/main/java/ru/ytkab0bp/slicebeam/components/bed_menu/ListBedMenu.java +++ b/app/src/main/java/ru/ytkab0bp/slicebeam/components/bed_menu/ListBedMenu.java @@ -32,6 +32,8 @@ public abstract class ListBedMenu extends BedMenu { recyclerView = new RecyclerView(ctx); recyclerView.setLayoutManager(new LinearLayoutManager(ctx, portrait ? RecyclerView.HORIZONTAL : RecyclerView.VERTICAL, false)); recyclerView.setItemAnimator(null); + recyclerView.setClipToPadding(false); + recyclerView.setClipChildren(false); adapter = new SimpleRecyclerAdapter() { @NonNull @Override diff --git a/app/src/main/java/ru/ytkab0bp/slicebeam/events/CloudFeaturesUpdatedEvent.java b/app/src/main/java/ru/ytkab0bp/slicebeam/events/CloudFeaturesUpdatedEvent.java new file mode 100644 index 0000000..33ba49e --- /dev/null +++ b/app/src/main/java/ru/ytkab0bp/slicebeam/events/CloudFeaturesUpdatedEvent.java @@ -0,0 +1,6 @@ +package ru.ytkab0bp.slicebeam.events; + +import ru.ytkab0bp.eventbus.Event; + +@Event +public class CloudFeaturesUpdatedEvent {} diff --git a/app/src/main/java/ru/ytkab0bp/slicebeam/events/CloudLoginStateUpdatedEvent.java b/app/src/main/java/ru/ytkab0bp/slicebeam/events/CloudLoginStateUpdatedEvent.java new file mode 100644 index 0000000..ce29678 --- /dev/null +++ b/app/src/main/java/ru/ytkab0bp/slicebeam/events/CloudLoginStateUpdatedEvent.java @@ -0,0 +1,6 @@ +package ru.ytkab0bp.slicebeam.events; + +import ru.ytkab0bp.eventbus.Event; + +@Event +public class CloudLoginStateUpdatedEvent {} diff --git a/app/src/main/java/ru/ytkab0bp/slicebeam/events/CloudModelsRemainingCountUpdatedEvent.java b/app/src/main/java/ru/ytkab0bp/slicebeam/events/CloudModelsRemainingCountUpdatedEvent.java new file mode 100644 index 0000000..e3d5b78 --- /dev/null +++ b/app/src/main/java/ru/ytkab0bp/slicebeam/events/CloudModelsRemainingCountUpdatedEvent.java @@ -0,0 +1,6 @@ +package ru.ytkab0bp.slicebeam.events; + +import ru.ytkab0bp.eventbus.Event; + +@Event +public class CloudModelsRemainingCountUpdatedEvent {} diff --git a/app/src/main/java/ru/ytkab0bp/slicebeam/events/CloudUserInfoUpdatedEvent.java b/app/src/main/java/ru/ytkab0bp/slicebeam/events/CloudUserInfoUpdatedEvent.java new file mode 100644 index 0000000..611f6a3 --- /dev/null +++ b/app/src/main/java/ru/ytkab0bp/slicebeam/events/CloudUserInfoUpdatedEvent.java @@ -0,0 +1,7 @@ +package ru.ytkab0bp.slicebeam.events; + +import ru.ytkab0bp.eventbus.Event; + +@Event +public class CloudUserInfoUpdatedEvent { +} diff --git a/app/src/main/java/ru/ytkab0bp/slicebeam/events/NeedDismissAIGeneratorMenu.java b/app/src/main/java/ru/ytkab0bp/slicebeam/events/NeedDismissAIGeneratorMenu.java new file mode 100644 index 0000000..eeaff86 --- /dev/null +++ b/app/src/main/java/ru/ytkab0bp/slicebeam/events/NeedDismissAIGeneratorMenu.java @@ -0,0 +1,6 @@ +package ru.ytkab0bp.slicebeam.events; + +import ru.ytkab0bp.eventbus.Event; + +@Event +public class NeedDismissAIGeneratorMenu {} \ No newline at end of file diff --git a/app/src/main/java/ru/ytkab0bp/slicebeam/fragment/BedFragment.java b/app/src/main/java/ru/ytkab0bp/slicebeam/fragment/BedFragment.java index bc8543a..a421846 100644 --- a/app/src/main/java/ru/ytkab0bp/slicebeam/fragment/BedFragment.java +++ b/app/src/main/java/ru/ytkab0bp/slicebeam/fragment/BedFragment.java @@ -15,9 +15,6 @@ import android.util.TypedValue; import android.view.Gravity; import android.view.View; import android.view.ViewGroup; -import android.webkit.WebChromeClient; -import android.webkit.WebResourceRequest; -import android.webkit.WebResourceResponse; import android.webkit.WebView; import android.webkit.WebViewClient; import android.widget.FrameLayout; @@ -116,6 +113,7 @@ public class BedFragment extends Fragment { private UnfoldMenu currentUnfoldMenu; private BedSwipeDownLayout swipeDownLayout; + private boolean hasWebError; private WebView panelWebView; private LinearLayout panelWebViewError; private ImageView webViewErrIcon; @@ -229,7 +227,7 @@ public class BedFragment extends Fragment { super.onResume(); glView.onResume(); ConfigObject cfg = SliceBeam.CONFIG.findPrinter(SliceBeam.CONFIG.presets.get("printer")); - boolean enable = cfg != null && cfg.get("host_type") != null && !TextUtils.isEmpty(cfg.get("print_host")); + boolean enable = cfg != null && cfg.get("host_type") != null && !TextUtils.isEmpty(cfg.get("print_host")) && panelWebView != null; swipeDownLayout.setEnableTop(enable); if (enable) { String host = cfg.get("print_host"); @@ -246,6 +244,7 @@ public class BedFragment extends Fragment { } webViewProgressBar.animate().alpha(1).setDuration(150).start(); panelWebView.setAlpha(0f); + hasWebError = false; panelWebView.loadUrl(host); panelWebViewError.animate().alpha(0).setDuration(150).setListener(new AnimatorListenerAdapter() { @Override @@ -294,46 +293,58 @@ public class BedFragment extends Fragment { } swipeDownLayout = new BedSwipeDownLayout(ctx); - panelWebView = new WebView(ctx); - panelWebView.getSettings().setJavaScriptEnabled(true); - panelWebView.setWebViewClient(new WebViewClient() { - @Override - public void onReceivedError(WebView view, int errorCode, String description, String failingUrl) { - webViewErrDescription.setText(description); - panelWebViewError.setVisibility(View.VISIBLE); - panelWebViewError.setAlpha(0f); - panelWebViewError.animate().alpha(1).setDuration(150).setListener(null).start(); - webViewProgressBar.animate().alpha(0).setDuration(150).start(); - } - - @Override - public void onPageFinished(WebView view, String url) { - panelWebView.animate().alpha(1).setDuration(150).start(); - webViewProgressBar.animate().alpha(0).setDuration(150).start(); - } - }); - FrameLayout wfl = new FrameLayout(ctx); - wfl.addView(panelWebView, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); - panelWebViewError = new LinearLayout(ctx); - panelWebViewError.setVisibility(View.GONE); - panelWebViewError.setOrientation(LinearLayout.VERTICAL); - panelWebViewError.setGravity(Gravity.CENTER); - panelWebViewError.setPadding(ViewUtils.dp(12), ViewUtils.dp(12), ViewUtils.dp(12), ViewUtils.dp(12)); - webViewErrIcon = new ImageView(ctx); - webViewErrIcon.setImageResource(R.drawable.globe_cross_outline_28); - panelWebViewError.addView(webViewErrIcon, new LinearLayout.LayoutParams(ViewUtils.dp(28), ViewUtils.dp(28)) {{ - bottomMargin = ViewUtils.dp(8); - }}); - webViewErrDescription = new TextView(ctx); - webViewErrDescription.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 16); - webViewErrDescription.setGravity(Gravity.CENTER); - panelWebViewError.addView(webViewErrDescription); - wfl.addView(panelWebViewError, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); + try { + panelWebView = new WebView(ctx); + panelWebView.getSettings().setJavaScriptEnabled(true); + panelWebView.setWebViewClient(new WebViewClient() { + @Override + public void onReceivedError(WebView view, int errorCode, String description, String failingUrl) { + hasWebError = true; + webViewErrDescription.setText(description); + panelWebViewError.setVisibility(View.VISIBLE); + panelWebViewError.setAlpha(0f); + panelWebViewError.animate().alpha(1).setDuration(150).setListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + panelWebView.setVisibility(View.GONE); + } + }).start(); + webViewProgressBar.animate().alpha(0).setDuration(150).start(); + } - webViewProgressBar = new ProgressBar(ctx); - webViewProgressBar.setAlpha(0f); - wfl.addView(webViewProgressBar, new FrameLayout.LayoutParams(ViewUtils.dp(36), ViewUtils.dp(36), Gravity.CENTER)); + @Override + public void onPageFinished(WebView view, String url) { + if (!hasWebError) { + panelWebView.animate().alpha(1).setDuration(150).start(); + webViewProgressBar.animate().alpha(0).setDuration(150).start(); + } + } + }); + + wfl.addView(panelWebView, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); + panelWebViewError = new LinearLayout(ctx); + panelWebViewError.setVisibility(View.GONE); + panelWebViewError.setOrientation(LinearLayout.VERTICAL); + panelWebViewError.setGravity(Gravity.CENTER); + panelWebViewError.setPadding(ViewUtils.dp(12), ViewUtils.dp(12), ViewUtils.dp(12), ViewUtils.dp(12)); + webViewErrIcon = new ImageView(ctx); + webViewErrIcon.setImageResource(R.drawable.globe_cross_outline_28); + panelWebViewError.addView(webViewErrIcon, new LinearLayout.LayoutParams(ViewUtils.dp(28), ViewUtils.dp(28)) {{ + bottomMargin = ViewUtils.dp(8); + }}); + webViewErrDescription = new TextView(ctx); + webViewErrDescription.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 16); + webViewErrDescription.setGravity(Gravity.CENTER); + panelWebViewError.addView(webViewErrDescription); + wfl.addView(panelWebViewError, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); + + webViewProgressBar = new ProgressBar(ctx); + webViewProgressBar.setAlpha(0f); + wfl.addView(webViewProgressBar, new FrameLayout.LayoutParams(ViewUtils.dp(36), ViewUtils.dp(36), Gravity.CENTER)); + } catch (Exception e) { + Log.wtf("BedFragment", "Failed to initialize webview", e); + } if (portrait) { LinearLayout inner = new LinearLayout(ctx); @@ -598,9 +609,11 @@ public class BedFragment extends Fragment { public void onApplyTheme() { super.onApplyTheme(); - webViewErrIcon.setImageTintList(ColorStateList.valueOf(ThemesRepo.getColor(android.R.attr.textColorSecondary))); - webViewErrDescription.setTextColor(ThemesRepo.getColor(android.R.attr.textColorSecondary)); - webViewProgressBar.setIndeterminateTintList(ColorStateList.valueOf(ThemesRepo.getColor(android.R.attr.textColorSecondary))); + if (panelWebView != null) { + webViewErrIcon.setImageTintList(ColorStateList.valueOf(ThemesRepo.getColor(android.R.attr.textColorSecondary))); + webViewErrDescription.setTextColor(ThemesRepo.getColor(android.R.attr.textColorSecondary)); + webViewProgressBar.setIndeterminateTintList(ColorStateList.valueOf(ThemesRepo.getColor(android.R.attr.textColorSecondary))); + } menuView.setBackgroundColor(ThemesRepo.getColor(android.R.attr.windowBackground)); for (int i = 0; i < MenuCategory.values().length; i++) { if (i != currentMenuSlot) { diff --git a/app/src/main/java/ru/ytkab0bp/slicebeam/fragment/ProfileListFragment.java b/app/src/main/java/ru/ytkab0bp/slicebeam/fragment/ProfileListFragment.java index 7d7e769..d7a2620 100644 --- a/app/src/main/java/ru/ytkab0bp/slicebeam/fragment/ProfileListFragment.java +++ b/app/src/main/java/ru/ytkab0bp/slicebeam/fragment/ProfileListFragment.java @@ -6,6 +6,8 @@ import android.content.res.ColorStateList; import android.graphics.Canvas; import android.graphics.Color; import android.graphics.Paint; +import android.graphics.Path; +import android.graphics.RectF; import android.text.Editable; import android.text.InputType; import android.text.TextUtils; @@ -34,6 +36,8 @@ import androidx.dynamicanimation.animation.SpringAnimation; import androidx.dynamicanimation.animation.SpringForce; import androidx.recyclerview.widget.RecyclerView; +import com.bumptech.glide.Glide; +import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions; import com.mrudultora.colorpicker.ColorPickerPopUp; import java.util.ArrayList; @@ -47,6 +51,8 @@ import java.util.concurrent.atomic.AtomicReference; import ru.ytkab0bp.slicebeam.R; import ru.ytkab0bp.slicebeam.SliceBeam; +import ru.ytkab0bp.slicebeam.cloud.CloudAPI; +import ru.ytkab0bp.slicebeam.cloud.CloudController; import ru.ytkab0bp.slicebeam.components.BeamAlertDialogBuilder; import ru.ytkab0bp.slicebeam.components.BeamColorPickerPopUp; import ru.ytkab0bp.slicebeam.config.ConfigObject; @@ -56,6 +62,7 @@ import ru.ytkab0bp.slicebeam.recycler.PreferenceItem; import ru.ytkab0bp.slicebeam.recycler.PreferenceSwitchItem; import ru.ytkab0bp.slicebeam.recycler.SimpleRecyclerItem; import ru.ytkab0bp.slicebeam.slic3r.ConfigOptionDef; +import ru.ytkab0bp.slicebeam.slic3r.PrintConfigDef; import ru.ytkab0bp.slicebeam.slic3r.Slic3rConfigWrapper; import ru.ytkab0bp.slicebeam.slic3r.Slic3rLocalization; import ru.ytkab0bp.slicebeam.theme.IThemeView; @@ -68,6 +75,8 @@ import ru.ytkab0bp.slicebeam.view.FadeRecyclerView; import ru.ytkab0bp.slicebeam.view.ProfileDropdownView; public abstract class ProfileListFragment extends Fragment { + public final static int SPECIAL_TYPE_CLOUD_HEADER = 0; + private final static Object ROTATION_PAYLOAD = new Object(); protected ProfileDropdownView dropdownView; @@ -146,8 +155,8 @@ public abstract class ProfileListFragment extends Fragment { int pos = getChildViewHolder(ch).getAdapterPosition(); if (pos == -1 || ch.getAlpha() < 1) continue; - boolean top = currentList.get(pos).title != null; - boolean bottom = pos == getAdapter().getItemCount() - 1 || currentList.get(pos + 1).title != null; + boolean top = currentList.get(pos).title != null || currentList.get(pos).hasSpecialType(); + boolean bottom = pos == getAdapter().getItemCount() - 1 || currentList.get(pos + 1).title != null || currentList.get(pos + 1).hasSpecialType(); if (top && startI != -1) { c.drawRoundRect(0, getChildAt(startI).getTop() + getChildAt(startI).getTranslationY(), getWidth(), ch.getTop() + ch.getTranslationY() - ViewUtils.dp(8), ViewUtils.dp(32), ViewUtils.dp(32), bgPaint); @@ -204,7 +213,7 @@ public abstract class ProfileListFragment extends Fragment { }; recyclerView.setItemAnimator(new CubicBezierItemAnimator()); recyclerView.setAdapter(new RecyclerView.Adapter() { - private final static int TYPE_TITLE = 0, TYPE_SIMPLE = 1; + private final static int TYPE_TITLE = 0, TYPE_CLOUD_PROFILE = 1, TYPE_SIMPLE = 2; private Map, Integer> viewType = new HashMap<>(); private Map viewCreator = new HashMap<>(); @@ -219,6 +228,9 @@ public abstract class ProfileListFragment extends Fragment { v = viewCreator.get(viewType).onCreateView(ctx); break; } + case TYPE_CLOUD_PROFILE: + v = new CloudProfileHeaderView(ctx); + break; case TYPE_TITLE: v = new CategoryHolderView(ctx); break; @@ -258,6 +270,43 @@ public abstract class ProfileListFragment extends Fragment { el.simpleItem.onBindView(holder.itemView); break; } + case TYPE_CLOUD_PROFILE: { + OptionWrapper w = currentList.get(position); + CloudProfileHeaderView holderView = (CloudProfileHeaderView) holder.itemView; + holderView.setTag(w.color); + if (Prefs.getCloudAPIToken() != null) { + CloudAPI.UserInfo info = CloudController.getUserInfo(); + if (info != null) { + if (!TextUtils.isEmpty(info.avatarUrl)) { + holderView.hasAvatar = true; + Glide.with(holderView.avatar) + .load(info.avatarUrl) + .circleCrop() + .transition(DrawableTransitionOptions.withCrossFade()) + .into(holderView.avatar); + } else { + holderView.hasAvatar = false; + holderView.avatar.setImageResource(R.drawable.user_circle_outline_28); + } + + holderView.title.setText(info.displayName); + } else { + holderView.hasAvatar = false; + holderView.avatar.setImageResource(R.drawable.user_circle_outline_28); + + holderView.title.setText(R.string.SettingsCloudLoading); + } + holderView.subtitle.setText(R.string.SettingsCloudTapToManage); + } else { + holderView.hasAvatar = false; + holderView.avatar.setImageResource(R.drawable.user_circle_outline_28); + holderView.title.setText(R.string.SettingsCloudNotLoggedIn); + holderView.subtitle.setText(R.string.SettingsCloudTapToShowMore); + } + holderView.onApplyTheme(); + holderView.setOnClickListener(view -> w.onClick.run()); + break; + } case TYPE_TITLE: { OptionWrapper w = currentList.get(position); CategoryHolderView holderView = (CategoryHolderView) holder.itemView; @@ -301,6 +350,13 @@ public abstract class ProfileListFragment extends Fragment { @Override public int getItemViewType(int position) { OptionWrapper w = currentList.get(position); + if (w.optionEl != null && w.optionEl.specialType != -1) { + switch (w.optionEl.specialType) { + default: + case SPECIAL_TYPE_CLOUD_HEADER: + return TYPE_CLOUD_PROFILE; + } + } if (w.title != null) return TYPE_TITLE; if (w.optionEl.simpleItem != null) { @@ -469,7 +525,12 @@ public abstract class ProfileListFragment extends Fragment { OptionElement el = items.get(i); if (el == null) continue; OptionWrapper w = el.title != null ? new OptionWrapper(el.icon, el.title, el.onClick, el.color, el.noTint) : new OptionWrapper(el); - if (el.title != null) { + if (el.specialType != -1) { + w.color = el.color; + w.noTint = el.noTint; + w.onClick = el.onClick; + } + if (el.title != null || el.specialType != -1) { w.categoryIndex = j; categoryElements.put(j, new ArrayList<>()); j++; @@ -563,6 +624,8 @@ public abstract class ProfileListFragment extends Fragment { } public final class OptionElement { + public int specialType = -1; + public int icon; public String title; public int color; @@ -916,6 +979,10 @@ public abstract class ProfileListFragment extends Fragment { optionEl = el; } + boolean hasSpecialType() { + return optionEl != null && optionEl.specialType != -1; + } + @Override public View onCreateView(Context ctx) { FrameLayout v = new FrameLayout(ctx); @@ -926,6 +993,49 @@ public abstract class ProfileListFragment extends Fragment { } } + private final static class CloudProfileHeaderView extends LinearLayout implements IThemeView { + private ImageView avatar; + private TextView title; + private TextView subtitle; + private boolean hasAvatar; + + public CloudProfileHeaderView(Context context) { + super(context); + + setOrientation(HORIZONTAL); + setGravity(Gravity.CENTER_VERTICAL); + setPadding(ViewUtils.dp(21), ViewUtils.dp(16), ViewUtils.dp(21), ViewUtils.dp(16)); + + avatar = new ImageView(context); + addView(avatar, new LayoutParams(ViewUtils.dp(26), ViewUtils.dp(26)) {{ + setMarginEnd(ViewUtils.dp(12)); + }}); + + LinearLayout ll = new LinearLayout(context); + ll.setOrientation(VERTICAL); + ll.setGravity(Gravity.CENTER); + title = new TextView(context); + title.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 16); + title.setTypeface(ViewUtils.getTypeface(ViewUtils.ROBOTO_MEDIUM)); + ll.addView(title); + subtitle = new TextView(context); + subtitle.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 14); + ll.addView(subtitle); + addView(ll, new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)); + + onApplyTheme(); + } + + @Override + public void onApplyTheme() { + setBackground(ViewUtils.createRipple(ThemesRepo.getColor(android.R.attr.colorControlHighlight), 32)); + title.setTextColor(ThemesRepo.getColor(android.R.attr.textColorPrimary)); + subtitle.setTextColor(ThemesRepo.getColor(android.R.attr.textColorSecondary)); + if (!hasAvatar) { + avatar.setImageTintList(ColorStateList.valueOf(ThemesRepo.getColor(android.R.attr.textColorSecondary))); + } + } + } private final static class CategoryHolderView extends LinearLayout implements IThemeView { private ImageView icon; private TextView title; diff --git a/app/src/main/java/ru/ytkab0bp/slicebeam/fragment/SettingsFragment.java b/app/src/main/java/ru/ytkab0bp/slicebeam/fragment/SettingsFragment.java index bfdbefb..23ada81 100644 --- a/app/src/main/java/ru/ytkab0bp/slicebeam/fragment/SettingsFragment.java +++ b/app/src/main/java/ru/ytkab0bp/slicebeam/fragment/SettingsFragment.java @@ -28,6 +28,7 @@ import ru.ytkab0bp.slicebeam.components.BeamAlertDialogBuilder; import ru.ytkab0bp.slicebeam.components.BeamColorPickerPopUp; import ru.ytkab0bp.slicebeam.config.ConfigObject; import ru.ytkab0bp.slicebeam.events.BeamServerDataUpdatedEvent; +import ru.ytkab0bp.slicebeam.events.CloudUserInfoUpdatedEvent; import ru.ytkab0bp.slicebeam.recycler.PreferenceItem; import ru.ytkab0bp.slicebeam.theme.BeamTheme; import ru.ytkab0bp.slicebeam.theme.ThemesRepo; @@ -45,6 +46,10 @@ public class SettingsFragment extends ProfileListFragment { @Override protected List getConfigItems() { return Arrays.asList( + BeamServerData.isCloudAvailable() ? new OptionElement(SPECIAL_TYPE_CLOUD_HEADER).setOnClick(() -> { + Activity act = (Activity) getContext(); + act.startActivity(new Intent(act, SetupActivity.class).putExtra(SetupActivity.EXTRA_CLOUD_PROFILE, true)); + }) : null, new OptionElement(R.drawable.paint_roller_outline_28, getContext().getString(R.string.SettingsInterface)), new OptionElement(new PreferenceItem().setTitle(getContext().getString(R.string.SettingsInterfaceTheme)).setValueProvider(() -> getContext().getString(Prefs.getThemeMode().title)).setOnClickListener(v -> { String[] items = new String[Prefs.ThemeMode.values().length]; @@ -107,7 +112,7 @@ public class SettingsFragment extends ProfileListFragment { BeamTheme.LIGHT.colors.put(android.R.attr.colorAccent, Prefs.getAccentColor()); BeamTheme.DARK.colors.put(android.R.attr.colorAccent, Prefs.getAccentColor()); ThemesRepo.invalidate((Activity) getContext()); - recyclerView.getAdapter().notifyItemChanged(1); + recyclerView.getAdapter().notifyItemChanged(2 - (BeamServerData.isCloudAvailable() ? 0 : 1)); } }) .setNegativeButtonText(getContext().getString(R.string.SettingsInterfaceColorReset)) @@ -130,7 +135,7 @@ public class SettingsFragment extends ProfileListFragment { Prefs.setRenderScale(variants[which]); dialog.dismiss(); // I'm too lazy to calculate real position for now - recyclerView.getAdapter().notifyItemChanged(3); + recyclerView.getAdapter().notifyItemChanged(4 - (BeamServerData.isCloudAvailable() ? 0 : 1)); }) .show(); })), @@ -177,6 +182,13 @@ public class SettingsFragment extends ProfileListFragment { setConfigItems(getConfigItems()); } + @EventHandler(runOnMainThread = true) + public void onUserInfoUpdated(CloudUserInfoUpdatedEvent e) { + if (BeamServerData.isCloudAvailable()) { + recyclerView.getAdapter().notifyItemChanged(0); + } + } + @Override protected void cloneCurrentProfile() {} diff --git a/app/src/main/java/ru/ytkab0bp/slicebeam/recycler/PreferenceItem.java b/app/src/main/java/ru/ytkab0bp/slicebeam/recycler/PreferenceItem.java index c3d2daf..da6b3aa 100644 --- a/app/src/main/java/ru/ytkab0bp/slicebeam/recycler/PreferenceItem.java +++ b/app/src/main/java/ru/ytkab0bp/slicebeam/recycler/PreferenceItem.java @@ -21,6 +21,7 @@ import androidx.core.content.ContextCompat; import androidx.recyclerview.widget.RecyclerView; import ru.ytkab0bp.slicebeam.SliceBeam; +import ru.ytkab0bp.slicebeam.theme.BeamTheme; import ru.ytkab0bp.slicebeam.theme.IThemeView; import ru.ytkab0bp.slicebeam.theme.ThemesRepo; import ru.ytkab0bp.slicebeam.utils.ViewUtils; @@ -35,6 +36,8 @@ public class PreferenceItem extends SimpleRecyclerItem typefaceCache = new HashMap<>(); + + public static Handler getUiHandler() { + return uiHandler; + } + public static void postOnMainThread(Runnable runnable) { uiHandler.post(runnable); } diff --git a/app/src/main/java/ru/ytkab0bp/slicebeam/view/SegmentsView.java b/app/src/main/java/ru/ytkab0bp/slicebeam/view/SegmentsView.java index 49702e1..7515c93 100644 --- a/app/src/main/java/ru/ytkab0bp/slicebeam/view/SegmentsView.java +++ b/app/src/main/java/ru/ytkab0bp/slicebeam/view/SegmentsView.java @@ -110,6 +110,10 @@ public class SegmentsView extends View { } } + protected int onGetColor(int i) { + return mapColor(i); + } + @Override protected void onDraw(@NonNull Canvas canvas) { super.onDraw(canvas); @@ -125,7 +129,7 @@ public class SegmentsView extends View { for (int i = 1; i < currentValues.length; i++) { float prev = currentValues[i - 1]; float to = currentValues[i]; - paint.setColor(mapColor(i - 1)); + paint.setColor(onGetColor(i - 1)); canvas.drawRect(l + prev * dw, 0, l + to * dw, getHeight(), paint); } } diff --git a/app/src/main/jni/slicebeam/beam_native.cpp b/app/src/main/jni/slicebeam/beam_native.cpp index ede0af4..737b19d 100644 --- a/app/src/main/jni/slicebeam/beam_native.cpp +++ b/app/src/main/jni/slicebeam/beam_native.cpp @@ -1243,17 +1243,23 @@ extern "C" { JNIEXPORT jint JNICALL Java_ru_ytkab0bp_slicebeam_slic3r_Native_shader_1get_1uniform_1location(JNIEnv* env, jclass, jlong ptr, jstring name) { const char* chars = env->GetStringUTFChars(name, JNI_FALSE); ShaderRef* shader = (ShaderRef*) (intptr_t) ptr; - int location = shader->program.get_uniform_location(chars); - env->ReleaseStringUTFChars(name, chars); - return location; + if (shader) { + int location = shader->program.get_uniform_location(chars); + env->ReleaseStringUTFChars(name, chars); + return location; + } + return 0; } JNIEXPORT jint JNICALL Java_ru_ytkab0bp_slicebeam_slic3r_Native_shader_1get_1attrib_1location(JNIEnv* env, jclass, jlong ptr, jstring name) { - const char* chars = env->GetStringUTFChars(name, JNI_FALSE); - ShaderRef* shader = (ShaderRef*) (intptr_t) ptr; - int location = shader->program.get_attrib_location(chars); - env->ReleaseStringUTFChars(name, chars); - return location; + const char *chars = env->GetStringUTFChars(name, JNI_FALSE); + ShaderRef *shader = (ShaderRef *) (intptr_t) ptr; + if (shader) { + int location = shader->program.get_attrib_location(chars); + env->ReleaseStringUTFChars(name, chars); + return location; + } + return 0; } JNIEXPORT void JNICALL Java_ru_ytkab0bp_slicebeam_slic3r_Native_shader_1start_1using(JNIEnv* env, jclass, jlong ptr) { diff --git a/app/src/main/res/drawable/box_heart_outline_28.xml b/app/src/main/res/drawable/box_heart_outline_28.xml new file mode 100644 index 0000000..1fffd7b --- /dev/null +++ b/app/src/main/res/drawable/box_heart_outline_28.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/brain_outline_28.xml b/app/src/main/res/drawable/brain_outline_28.xml new file mode 100644 index 0000000..c4a1b2e --- /dev/null +++ b/app/src/main/res/drawable/brain_outline_28.xml @@ -0,0 +1,26 @@ + + + + + + + diff --git a/app/src/main/res/drawable/cloud_plus_outline_28.xml b/app/src/main/res/drawable/cloud_plus_outline_28.xml new file mode 100644 index 0000000..386d925 --- /dev/null +++ b/app/src/main/res/drawable/cloud_plus_outline_28.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/external_link_outline_24.xml b/app/src/main/res/drawable/external_link_outline_24.xml new file mode 100644 index 0000000..e1207b4 --- /dev/null +++ b/app/src/main/res/drawable/external_link_outline_24.xml @@ -0,0 +1,14 @@ + + + + diff --git a/app/src/main/res/drawable/hand_point_up_outline_28.xml b/app/src/main/res/drawable/hand_point_up_outline_28.xml deleted file mode 100644 index 1375ce6..0000000 --- a/app/src/main/res/drawable/hand_point_up_outline_28.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/picture_outline_28.xml b/app/src/main/res/drawable/picture_outline_28.xml new file mode 100644 index 0000000..53c639c --- /dev/null +++ b/app/src/main/res/drawable/picture_outline_28.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/picture_stack_outline_28.xml b/app/src/main/res/drawable/picture_stack_outline_28.xml new file mode 100644 index 0000000..4f4cd1b --- /dev/null +++ b/app/src/main/res/drawable/picture_stack_outline_28.xml @@ -0,0 +1,12 @@ + + + diff --git a/app/src/main/res/drawable/sparkle_28.xml b/app/src/main/res/drawable/sparkle_28.xml new file mode 100644 index 0000000..c961007 --- /dev/null +++ b/app/src/main/res/drawable/sparkle_28.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/stars_outline_28.xml b/app/src/main/res/drawable/stars_outline_28.xml new file mode 100644 index 0000000..5ede2a1 --- /dev/null +++ b/app/src/main/res/drawable/stars_outline_28.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/user_circle_outline_28.xml b/app/src/main/res/drawable/user_circle_outline_28.xml new file mode 100644 index 0000000..d2735f4 --- /dev/null +++ b/app/src/main/res/drawable/user_circle_outline_28.xml @@ -0,0 +1,12 @@ + + + diff --git a/app/src/main/res/drawable/zero_ruble_outline_28.xml b/app/src/main/res/drawable/zero_ruble_outline_28.xml new file mode 100644 index 0000000..811a77f --- /dev/null +++ b/app/src/main/res/drawable/zero_ruble_outline_28.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 9d19927..08533c3 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -16,6 +16,18 @@ Файл содержит более 500к треугольников. Нарезка может быть медленной. Загрузка файла… Убрать модель + Модель\nпо фото + Пожалуйста, подождите… + Ошибка: данные о пользователе пока не загружены. + Сделать фото + Выбрать из галереи + Осталось: %d / %d генераций + Загрузка изображения… + Обработка изображения… + Скачивание модели… + Не удалось сгенерировать модель + Модель сохранена как %s. + Не осталось генераций. Калибров. K3D Linear Advance Калибровка Linear/Pressure Advance @@ -155,6 +167,31 @@ %s - Копия Клон. текущий Удалить текущий + Не авторизовано + Загрузка… + Нажмите для управления + Нажмите чтобы узнать больше + Аккаунт Beam 3D + Даёт следующие преимущества: + Облачная синхронизация профилей + Храните свои профили в облаке Beam + ИИ генератор моделей + %1$d моделей по фото в месяц + Slice Beam может оставаться бесплатным для всех + Спасибо за вашу поддержку! + При подписке на данный уровень вы соглашаетесь с условиями обслуживания. + Уже подписаны? + Бесплатно + Вы подписаны + Будет позже + Условия обслуживания + Настройки аккаунта + Войти + Отмена + Отменить авторизацию? + Выйти + Вошли как «%1$s» + Управление подпиской Список изменений Выход данного обновления поддержали: Далее @@ -162,9 +199,19 @@ Конвертация профилей, пожалуйста, подождите… Это не пакет конфигураций Что-то пошло не так - Версия Android: %s\nУстройство: %s\nЛог: \n%s + Версия Android: %1$s\nУстройство: %2$s\nЛог: \n%3$s Поделиться Попытаться запустить приложение ещё раз Ошибка конфигурации стола Вам необходимо исправить конфигурацию стола перед использованием. + Синхронизация с облаком… + Успешно синхронизировали профили. + Не удалось синхронизировать профили, повторим попытку позже. + Конфликт облачных профилей. + Разрешить + Конфликт облачных профилей, пожалуйста, выберите какие профили вы хотите оставить.\n\nПоследнее изменение в облаке: %1$s\nПоследнее изменение на устройстве: %2$s + Оставить облачные профили + Оставить локальные профили + Да + Нет \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c2072ea..c2f312a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -17,6 +17,18 @@ File has more than 500k triangles. Processing could be slow. Loading file… Remove model + Model\nfrom photo + Please wait… + Error: user info not fetched yet. + Take a photo + Choose from gallery + Remaining: %d / %d generations + Uploading image… + Processing image… + Downloading model… + Failed to generate model + Saved model as %s. + No generations left. Calibrat. K3D Linear Advance Linear/Pressure Advance Calibration @@ -157,6 +169,31 @@ %s - Copy Clone current Delete current + Not logged in + Loading… + Tap to manage + Tap to learn more + Beam 3D Account + Provides the following benefits: + Cloud profiles sync + Store your profiles in Beam Cloud + AI model generator + %1$d models from photo per month + Slice Beam can remain free for all + Thanks for your support! + By subscribing to this level you accept terms of service. + Already subscribed? + Free + You are subscribed + Will be later + Terms of service + Account settings + Login + Cancel + Cancel login? + Log out + Logged in as «%1$s» + Manage subscription Changelog Boosty The release of this update was supported by: @@ -165,9 +202,19 @@ Converting profiles, please wait… Not a config bundle Something went wrong - Android version: %s\nDevice: %s\nLogs:\n%s + Android version: %1$s\nDevice: %2$s\nLogs:\n%3$s Share Try to start app again Bed config error You should fix your bed configuration before usage. + Cloud synchronization in progress… + Successfully synchronized profiles. + Failed to synchronize profiles, will retry later. + Cloud profiles conflict. + Resolve + Cloud profiles conflict, please select which profiles you want to keep.\n\nLast changed cloud: %1$s\nLast changed on device: %2$s + Keep cloud profiles + Keep local profiles + Yes + No \ No newline at end of file diff --git a/settings.gradle b/settings.gradle index 6510bba..882e7ba 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,6 +1,7 @@ rootProject.name = "Slice Beam" -include ':app', ':eventbus', ':eventbus_api', ':eventbus_processor' +include ':app', ':eventbus', ':eventbus_api', ':eventbus_processor', ':sapil' project(':eventbus').projectDir = file('EventBus/eventbus') project(':eventbus_api').projectDir = file('EventBus/eventbus_api') project(':eventbus_processor').projectDir = file('EventBus/eventbus_processor') +project(':sapil').projectDir = file('SAPIL/sapil')