Sponsor: GhostCloud labeling, curved gloss streak, support link & top sponsors
- Bulletin now reads 'Sponsor GhostCloud' + granted for 444+ rubles (en/ru) - Heart highlight reworked: thin curved streak sweeping diagonally instead of an orbiting blob; palette tuned closer to the reference - SponsorHelper.loadTopSponsors() hits mobile donors/leaderboard via X-Tg-Id - New FoxSponsorsActivity lists the top sponsors - Neko settings: 'Support the project' (t.me/vpnghostbot) + 'Top sponsors'
This commit is contained in:
parent
9b81a463a6
commit
47f9aef160
6 changed files with 263 additions and 39 deletions
|
|
@ -1,5 +1,6 @@
|
|||
package tw.nekomimi.nekogram.helpers;
|
||||
|
||||
import android.graphics.BlurMaskFilter;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.ColorFilter;
|
||||
import android.graphics.LinearGradient;
|
||||
|
|
@ -16,9 +17,9 @@ import androidx.annotation.NonNull;
|
|||
import org.telegram.messenger.AndroidUtilities;
|
||||
|
||||
/**
|
||||
* A glossy 3D-looking heart badge with a soft multicolor gradient (purple/blue
|
||||
* to orange) and a moving glossy highlight ("blik") sweeping across it, like an
|
||||
* iridescent emoji sticker.
|
||||
* A glossy 3D-looking heart badge with a soft multicolor gradient (purple →
|
||||
* blue → orange) and a thin curved glossy streak ("blik") that slowly sweeps
|
||||
* diagonally across it, like light reflecting off a shiny sticker.
|
||||
*
|
||||
* It self-invalidates each frame, so when attached to a view via
|
||||
* {@code setRightDrawable(...)} / {@code setRightDrawable2(...)} the host keeps
|
||||
|
|
@ -29,14 +30,16 @@ public class ShimmerHeartDrawable extends Drawable {
|
|||
|
||||
private final Paint basePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Paint tintPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Paint highlightPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Paint shadePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Paint glossDotPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Paint streakPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Path heart = new Path();
|
||||
private final Path streak = new Path();
|
||||
|
||||
private int lastWidth = -1;
|
||||
private int lastHeight = -1;
|
||||
|
||||
private static final long CYCLE_MS = 3200L;
|
||||
private static final long CYCLE_MS = 3600L;
|
||||
private final int size;
|
||||
|
||||
public ShimmerHeartDrawable() {
|
||||
|
|
@ -47,8 +50,10 @@ public class ShimmerHeartDrawable extends Drawable {
|
|||
this.size = sizePx;
|
||||
basePaint.setStyle(Paint.Style.FILL);
|
||||
tintPaint.setStyle(Paint.Style.FILL);
|
||||
highlightPaint.setStyle(Paint.Style.FILL);
|
||||
shadePaint.setStyle(Paint.Style.FILL);
|
||||
glossDotPaint.setStyle(Paint.Style.FILL);
|
||||
streakPaint.setStyle(Paint.Style.STROKE);
|
||||
streakPaint.setStrokeCap(Paint.Cap.ROUND);
|
||||
}
|
||||
|
||||
private void buildHeart(Rect b) {
|
||||
|
|
@ -72,25 +77,35 @@ public class ShimmerHeartDrawable extends Drawable {
|
|||
float l = b.left;
|
||||
float t = b.top;
|
||||
|
||||
// Base diagonal gradient: deep purple (top-left) -> blue -> warm orange (bottom-right).
|
||||
// Base diagonal gradient matching the reference: violet (top-left) →
|
||||
// blue → cyan → warm orange (bottom-right).
|
||||
basePaint.setShader(new LinearGradient(
|
||||
l, t, l + w, t + h,
|
||||
new int[]{0xFF7A3CE0, 0xFF5B6BFF, 0xFF4FA8FF, 0xFFFF9A4D, 0xFFFF7A3C},
|
||||
new float[]{0f, 0.32f, 0.55f, 0.82f, 1f},
|
||||
l + w * 0.15f, t, l + w * 0.9f, t + h,
|
||||
new int[]{0xFF8E2DE2, 0xFF5B5BFF, 0xFF2F9BFF, 0xFFFFB24D, 0xFFFF8A3D},
|
||||
new float[]{0f, 0.30f, 0.55f, 0.85f, 1f},
|
||||
Shader.TileMode.CLAMP));
|
||||
|
||||
// A soft purple glow blob on the upper-left lobe for depth.
|
||||
// Soft violet glow on the upper-left lobe for depth.
|
||||
tintPaint.setShader(new RadialGradient(
|
||||
l + w * 0.30f, t + h * 0.30f, w * 0.55f,
|
||||
new int[]{0xCC8A4DFF, 0x00000000},
|
||||
l + w * 0.32f, t + h * 0.28f, w * 0.6f,
|
||||
new int[]{0x999B30FF, 0x00000000},
|
||||
null, Shader.TileMode.CLAMP));
|
||||
|
||||
// Soft inner shade at the bottom tip for a rounded 3D feel.
|
||||
// Inner shade at the bottom tip for a rounded 3D feel.
|
||||
shadePaint.setShader(new RadialGradient(
|
||||
l + w * 0.5f, t + h * 0.95f, w * 0.6f,
|
||||
new int[]{0x66351A6B, 0x00000000},
|
||||
l + w * 0.55f, t + h * 0.92f, w * 0.55f,
|
||||
new int[]{0x55401E7A, 0x00000000},
|
||||
null, Shader.TileMode.CLAMP));
|
||||
|
||||
// Fixed small specular dot, top-left (glossy sticker look).
|
||||
glossDotPaint.setShader(new RadialGradient(
|
||||
l + w * 0.33f, t + h * 0.27f, w * 0.16f,
|
||||
new int[]{0xE6FFFFFF, 0x00FFFFFF},
|
||||
null, Shader.TileMode.CLAMP));
|
||||
|
||||
streakPaint.setStrokeWidth(Math.max(1f, w * 0.10f));
|
||||
streakPaint.setMaskFilter(new BlurMaskFilter(Math.max(1f, w * 0.06f), BlurMaskFilter.Blur.NORMAL));
|
||||
|
||||
lastWidth = b.width();
|
||||
lastHeight = b.height();
|
||||
}
|
||||
|
|
@ -115,34 +130,46 @@ public class ShimmerHeartDrawable extends Drawable {
|
|||
|
||||
float w = b.width();
|
||||
float h = b.height();
|
||||
float phase = (System.currentTimeMillis() % CYCLE_MS) / (float) CYCLE_MS;
|
||||
double ang = phase * 2 * Math.PI;
|
||||
float l = b.left;
|
||||
float t = b.top;
|
||||
|
||||
int save = canvas.save();
|
||||
canvas.clipPath(heart);
|
||||
|
||||
// Base colors + purple glow + bottom shade.
|
||||
// Base colors + violet glow + bottom shade.
|
||||
canvas.drawPath(heart, basePaint);
|
||||
canvas.drawPath(heart, tintPaint);
|
||||
canvas.drawPath(heart, shadePaint);
|
||||
|
||||
// Moving glossy highlight: a soft bright blob orbiting inside the heart.
|
||||
float hx = b.left + w * (0.5f + 0.28f * (float) Math.cos(ang));
|
||||
float hy = b.top + h * (0.42f + 0.24f * (float) Math.sin(ang));
|
||||
float hr = w * 0.45f;
|
||||
highlightPaint.setShader(new RadialGradient(
|
||||
hx, hy, hr,
|
||||
new int[]{0xCCFFFFFF, 0x33FFFFFF, 0x00FFFFFF},
|
||||
new float[]{0f, 0.4f, 1f},
|
||||
Shader.TileMode.CLAMP));
|
||||
canvas.drawPath(heart, highlightPaint);
|
||||
// Moving curved glossy streak sweeping diagonally across the heart.
|
||||
float phase = (System.currentTimeMillis() % CYCLE_MS) / (float) CYCLE_MS;
|
||||
// travel from top-left (off-screen) to bottom-right (off-screen)
|
||||
float p = -0.4f + phase * 1.8f;
|
||||
float dx = w * p; // horizontal offset of the streak
|
||||
streak.reset();
|
||||
// A gently curved (bowed) vertical-ish line, slanted diagonally.
|
||||
float x0 = l + dx + w * 0.10f;
|
||||
float y0 = t - h * 0.10f;
|
||||
float xc = l + dx + w * 0.45f;
|
||||
float yc = t + h * 0.50f;
|
||||
float x1 = l + dx + w * 0.30f;
|
||||
float y1 = t + h * 1.10f;
|
||||
streak.moveTo(x0, y0);
|
||||
streak.quadTo(xc, yc, x1, y1);
|
||||
|
||||
// A small fixed top-left specular dot for a glossy sticker look.
|
||||
highlightPaint.setShader(new RadialGradient(
|
||||
b.left + w * 0.34f, b.top + h * 0.28f, w * 0.18f,
|
||||
new int[]{0xE6FFFFFF, 0x00FFFFFF},
|
||||
null, Shader.TileMode.CLAMP));
|
||||
canvas.drawPath(heart, highlightPaint);
|
||||
// Fade the streak in/out at the edges of the sweep.
|
||||
float edge = Math.min(1f, Math.min(phase, 1f - phase) * 3f);
|
||||
int alpha = (int) (200 * Math.max(0f, edge));
|
||||
int whiteCore = (alpha << 24) | 0x00FFFFFF;
|
||||
streakPaint.setShader(new LinearGradient(
|
||||
x0, y0, x1, y1,
|
||||
new int[]{0x00FFFFFF, whiteCore, 0x00FFFFFF},
|
||||
new float[]{0f, 0.5f, 1f},
|
||||
Shader.TileMode.CLAMP));
|
||||
canvas.drawPath(streak, streakPaint);
|
||||
|
||||
// Fixed specular dot.
|
||||
canvas.drawPath(heart, glossDotPaint);
|
||||
|
||||
canvas.restoreToCount(save);
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package tw.nekomimi.nekogram.helpers;
|
|||
|
||||
import android.content.SharedPreferences;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import org.telegram.messenger.AndroidUtilities;
|
||||
import org.telegram.messenger.ApplicationLoader;
|
||||
|
|
@ -13,6 +14,8 @@ import org.telegram.messenger.Utilities;
|
|||
import java.io.InputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Resolves the "sponsor" status of the current Telegram user against the
|
||||
|
|
@ -142,4 +145,93 @@ public final class SponsorHelper {
|
|||
NotificationCenter.getGlobalInstance().postNotificationName(NotificationCenter.sponsorStatusUpdated));
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Top sponsors leaderboard
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
public static class Sponsor {
|
||||
public int rank;
|
||||
public String name;
|
||||
public double totalAmount;
|
||||
public boolean isMe;
|
||||
}
|
||||
|
||||
public interface SponsorsCallback {
|
||||
/** Called on the UI thread. {@code sponsors} is null on error. */
|
||||
void onResult(List<Sponsor> sponsors);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the top-sponsors leaderboard from the backend. Authenticated by the
|
||||
* current account's Telegram ID. The callback runs on the UI thread.
|
||||
*/
|
||||
public static void loadTopSponsors(SponsorsCallback callback) {
|
||||
var user = UserConfig.getInstance(UserConfig.selectedAccount).getCurrentUser();
|
||||
final long telegramId = user != null ? user.id : 0;
|
||||
Utilities.globalQueue.postRunnable(() -> {
|
||||
List<Sponsor> result = fetchTopSponsors(telegramId);
|
||||
AndroidUtilities.runOnUIThread(() -> callback.onResult(result));
|
||||
});
|
||||
}
|
||||
|
||||
// @WorkerThread
|
||||
private static List<Sponsor> fetchTopSponsors(long telegramId) {
|
||||
HttpURLConnection connection = null;
|
||||
try {
|
||||
URL url = new URL(BASE_URL + "/api/auth/mobile/donors/leaderboard");
|
||||
connection = (HttpURLConnection) url.openConnection();
|
||||
connection.setInstanceFollowRedirects(true);
|
||||
connection.setConnectTimeout(15000);
|
||||
connection.setReadTimeout(15000);
|
||||
if (telegramId != 0) {
|
||||
connection.setRequestProperty("X-Tg-Id", String.valueOf(telegramId));
|
||||
}
|
||||
connection.setRequestProperty("Accept", "application/json");
|
||||
|
||||
int code = connection.getResponseCode();
|
||||
if (code != HttpURLConnection.HTTP_OK) {
|
||||
FileLog.d(TAG + ": donors/leaderboard returned HTTP " + code);
|
||||
return null;
|
||||
}
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
try (InputStream in = connection.getInputStream()) {
|
||||
byte[] buffer = new byte[4096];
|
||||
int read;
|
||||
while ((read = in.read(buffer)) >= 0) {
|
||||
sb.append(new String(buffer, 0, read, "UTF-8"));
|
||||
}
|
||||
}
|
||||
|
||||
JSONObject json = new JSONObject(sb.toString());
|
||||
if (!json.optBoolean("success", false)) {
|
||||
return null;
|
||||
}
|
||||
JSONArray board = json.optJSONArray("leaderboard");
|
||||
List<Sponsor> list = new ArrayList<>();
|
||||
if (board != null) {
|
||||
for (int i = 0; i < board.length(); i++) {
|
||||
JSONObject o = board.optJSONObject(i);
|
||||
if (o == null) {
|
||||
continue;
|
||||
}
|
||||
Sponsor s = new Sponsor();
|
||||
s.rank = o.optInt("rank", i + 1);
|
||||
s.name = o.optString("name", "");
|
||||
s.totalAmount = o.optDouble("total_amount", 0);
|
||||
s.isMe = o.optBoolean("is_me", false);
|
||||
list.add(s);
|
||||
}
|
||||
}
|
||||
return list;
|
||||
} catch (Exception e) {
|
||||
FileLog.e(e);
|
||||
return null;
|
||||
} finally {
|
||||
if (connection != null) {
|
||||
connection.disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,83 @@
|
|||
package tw.nekomimi.nekogram.settings;
|
||||
|
||||
import android.view.View;
|
||||
|
||||
import org.telegram.messenger.LocaleController;
|
||||
import org.telegram.messenger.R;
|
||||
import org.telegram.ui.Components.UItem;
|
||||
import org.telegram.ui.Components.UniversalAdapter;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
import tw.nekomimi.nekogram.helpers.SponsorHelper;
|
||||
|
||||
public class FoxSponsorsActivity extends BaseNekoSettingsActivity {
|
||||
|
||||
private final int headerRow = rowId++;
|
||||
private final int firstSponsorRow = 1000;
|
||||
|
||||
private List<SponsorHelper.Sponsor> sponsors = null;
|
||||
private boolean loading = true;
|
||||
private boolean failed = false;
|
||||
|
||||
@Override
|
||||
public boolean onFragmentCreate() {
|
||||
super.onFragmentCreate();
|
||||
load();
|
||||
return true;
|
||||
}
|
||||
|
||||
private void load() {
|
||||
loading = true;
|
||||
failed = false;
|
||||
SponsorHelper.loadTopSponsors(result -> {
|
||||
loading = false;
|
||||
if (result == null) {
|
||||
failed = true;
|
||||
sponsors = null;
|
||||
} else {
|
||||
failed = false;
|
||||
sponsors = result;
|
||||
}
|
||||
if (listView != null) {
|
||||
listView.adapter.update(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void fillItems(ArrayList<UItem> items, UniversalAdapter adapter) {
|
||||
items.add(UItem.asShadow(LocaleController.getString(R.string.FoxTopSponsorsAbout)));
|
||||
if (loading) {
|
||||
items.add(TextSettingsCellFactory.of(headerRow, LocaleController.getString(R.string.Loading)));
|
||||
return;
|
||||
}
|
||||
if (failed) {
|
||||
items.add(TextSettingsCellFactory.of(headerRow, LocaleController.getString(R.string.FoxTopSponsorsError)));
|
||||
return;
|
||||
}
|
||||
if (sponsors == null || sponsors.isEmpty()) {
|
||||
items.add(TextSettingsCellFactory.of(headerRow, LocaleController.getString(R.string.FoxTopSponsorsEmpty)));
|
||||
return;
|
||||
}
|
||||
int i = 0;
|
||||
for (SponsorHelper.Sponsor s : sponsors) {
|
||||
String title = s.rank + ". " + s.name + (s.isMe ? " (" + LocaleController.getString(R.string.FromYou) + ")" : "");
|
||||
String amount = String.format(Locale.getDefault(), "%,.0f \u20bd", s.totalAmount);
|
||||
items.add(TextSettingsCellFactory.of(firstSponsorRow + i, title, amount));
|
||||
i++;
|
||||
}
|
||||
items.add(UItem.asShadow(null));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onItemClick(UItem item, View view, int position, float x, float y) {
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getActionBarTitle() {
|
||||
return LocaleController.getString(R.string.FoxTopSponsors);
|
||||
}
|
||||
}
|
||||
|
|
@ -68,6 +68,8 @@ public class NekoSettingsActivity extends BaseNekoSettingsActivity implements Fa
|
|||
private final int sourceCodeRow = rowId++;
|
||||
private final int translationRow = rowId++;
|
||||
private final int donateRow = rowId++;
|
||||
private final int supportProjectRow = rowId++;
|
||||
private final int topSponsorsRow = rowId++;
|
||||
|
||||
private final int sponsorRow = 100;
|
||||
|
||||
|
|
@ -178,6 +180,10 @@ public class NekoSettingsActivity extends BaseNekoSettingsActivity implements Fa
|
|||
items.add(UItem.asButtonSubtext(donateRow, R.drawable.msg_input_like, LocaleController.getString(R.string.Donate), LocaleController.getString(R.string.DonateAbout)).slug("donate"));
|
||||
items.add(UItem.asShadow(null));
|
||||
|
||||
items.add(UItem.asButtonSubtext(supportProjectRow, R.drawable.msg_input_like, LocaleController.getString(R.string.FoxSupportProject), LocaleController.getString(R.string.FoxSupportProjectAbout)).slug("supportProject"));
|
||||
items.add(UItem.asButtonSubtext(topSponsorsRow, R.drawable.msg_premium_liststar, LocaleController.getString(R.string.FoxTopSponsors), LocaleController.getString(R.string.FoxTopSponsorsAbout)).slug("topSponsors"));
|
||||
items.add(UItem.asShadow(null));
|
||||
|
||||
newsList.clear();
|
||||
newsList.addAll(ConfigHelper.getNewsForSettings());
|
||||
if (!newsList.isEmpty()) {
|
||||
|
|
@ -214,6 +220,10 @@ public class NekoSettingsActivity extends BaseNekoSettingsActivity implements Fa
|
|||
getMessagesController().openByUserName(LocaleController.getString(R.string.OfficialChannelUsername), this, 1);
|
||||
} else if (id == donateRow) {
|
||||
presentFragment(new NekoDonateActivity());
|
||||
} else if (id == supportProjectRow) {
|
||||
Browser.openUrl(getParentActivity(), "https://t.me/vpnghostbot");
|
||||
} else if (id == topSponsorsRow) {
|
||||
presentFragment(new FoxSponsorsActivity());
|
||||
} else if (id == translationRow) {
|
||||
Browser.openUrl(getParentActivity(), "https://neko.crowdin.com/nekogram");
|
||||
} else if (id == websiteRow) {
|
||||
|
|
|
|||
|
|
@ -323,6 +323,12 @@
|
|||
<string name="StrokeOnViews">Эффекты бликов</string>
|
||||
<!-- Accessibility -->
|
||||
<string name="AccessibilitySettings">Настройки специальных возможностей</string>
|
||||
<string name="FoxSponsorBadge">Спонсор</string>
|
||||
<string name="FoxSponsorBadgeInfo">Эта отметка выдана за поддержку проекта. Спасибо!</string>
|
||||
<string name="FoxSponsorBadge">Спонсор GhostCloud</string>
|
||||
<string name="FoxSponsorBadgeInfo">Эта отметка выдана за поддержание проекта на 444+ рублей.</string>
|
||||
<string name="FoxSupportProject">Поддержать проект</string>
|
||||
<string name="FoxSupportProjectAbout">Оформите GhostCloud через нашего бота</string>
|
||||
<string name="FoxTopSponsors">Топ спонсоров</string>
|
||||
<string name="FoxTopSponsorsAbout">Те, кто поддерживает проект</string>
|
||||
<string name="FoxTopSponsorsEmpty">Пока нет спонсоров</string>
|
||||
<string name="FoxTopSponsorsError">Не удалось загрузить список</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -149,8 +149,14 @@
|
|||
<string name="ShareNekogram">Share FoxiGram...</string>
|
||||
<string name="NekogramVersion">FoxiGram %1$s\nBased on Telegram %2$s\nDesigned by %3$s</string>
|
||||
<string name="UpdateInstalling">Installing update...</string>
|
||||
<string name="FoxSponsorBadge">Sponsor</string>
|
||||
<string name="FoxSponsorBadgeInfo">You received this badge for supporting the project. Thank you!</string>
|
||||
<string name="FoxSponsorBadge">Sponsor GhostCloud</string>
|
||||
<string name="FoxSponsorBadgeInfo">This badge is granted for supporting the project with 444+ rubles.</string>
|
||||
<string name="FoxSupportProject">Support the project</string>
|
||||
<string name="FoxSupportProjectAbout">Subscribe to GhostCloud via our bot</string>
|
||||
<string name="FoxTopSponsors">Top sponsors</string>
|
||||
<string name="FoxTopSponsorsAbout">People who support the project</string>
|
||||
<string name="FoxTopSponsorsEmpty">No sponsors yet</string>
|
||||
<string name="FoxTopSponsorsError">Failed to load sponsors</string>
|
||||
<string name="UpdateDownloading">Downloading update...</string>
|
||||
<string name="UpdateInstallingNotification">A notification will be shown when the update completes.</string>
|
||||
<string name="UpdateInstallingRelaunch">The app will relaunch when the update completes.</string>
|
||||
|
|
|
|||
Loading…
Reference in a new issue