Initial implementation of donations

This commit is contained in:
Grishka
2024-04-15 16:36:59 +03:00
parent 1124bc48c2
commit b2d49c3143
26 changed files with 1606 additions and 13 deletions

View File

@@ -24,7 +24,6 @@ import org.joinmastodon.android.model.Notification;
import org.joinmastodon.android.model.PaginatedResponse;
import org.joinmastodon.android.model.SearchResult;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.utils.UiUtils;
import java.io.File;
import java.io.FileInputStream;
@@ -45,8 +44,8 @@ import me.grishka.appkit.utils.WorkerThread;
public class CacheController{
private static final String TAG="CacheController";
private static final int DB_VERSION=3;
private static final WorkerThread databaseThread=new WorkerThread("databaseThread");
private static final Handler uiHandler=new Handler(Looper.getMainLooper());
public static final WorkerThread databaseThread=new WorkerThread("databaseThread");
public static final Handler uiHandler=new Handler(Looper.getMainLooper());
private final String accountID;
private DatabaseHelper db;
@@ -467,9 +466,4 @@ public class CacheController{
db.execSQL("ALTER TABLE `notifications_mentions` ADD `time` INTEGER NOT NULL DEFAULT 0");
}
}
@FunctionalInterface
private interface DatabaseRunnable{
void run(SQLiteDatabase db) throws IOException;
}
}

View File

@@ -0,0 +1,10 @@
package org.joinmastodon.android.api;
import android.database.sqlite.SQLiteDatabase;
import java.io.IOException;
@FunctionalInterface
public interface DatabaseRunnable{
void run(SQLiteDatabase db) throws IOException;
}

View File

@@ -0,0 +1,28 @@
package org.joinmastodon.android.api.requests.catalog;
import android.net.Uri;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.model.donations.DonationCampaign;
public class GetDonationCampaigns extends MastodonAPIRequest<DonationCampaign>{
private final String locale, seed;
public GetDonationCampaigns(String locale, String seed){
super(HttpMethod.GET, null, DonationCampaign.class);
this.locale=locale;
this.seed=seed;
}
@Override
public Uri getURL(){
Uri.Builder builder=new Uri.Builder()
.scheme("https")
.authority("api.joinmastodon.org")
.path("/donations/campaigns")
.appendQueryParameter("platform", "android")
.appendQueryParameter("locale", locale)
.appendQueryParameter("seed", seed);
return builder.build();
}
}

View File

@@ -33,7 +33,6 @@ import org.joinmastodon.android.model.TimelineMarkers;
import org.joinmastodon.android.model.Token;
import org.joinmastodon.android.utils.ObjectIdComparator;
import java.io.File;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
@@ -276,4 +275,12 @@ public class AccountSession{
public void setNotificationsMentionsOnly(boolean mentionsOnly){
getRawLocalPreferences().edit().putBoolean("notificationsMentionsOnly", mentionsOnly).apply();
}
public boolean isEligibleForDonations(){
return "mastodon.social".equalsIgnoreCase(domain) || "mastodon.online".equalsIgnoreCase(domain);
}
public int getDonationSeed(){
return Math.abs(getFullUsername().hashCode())%100;
}
}

View File

@@ -3,11 +3,16 @@ package org.joinmastodon.android.api.session;
import android.app.Activity;
import android.app.NotificationManager;
import android.content.ComponentName;
import android.content.ContentValues;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.pm.ShortcutInfo;
import android.content.pm.ShortcutManager;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteException;
import android.database.sqlite.SQLiteOpenHelper;
import android.graphics.drawable.Icon;
import android.net.Uri;
import android.os.Build;
@@ -18,11 +23,13 @@ import org.joinmastodon.android.E;
import org.joinmastodon.android.MainActivity;
import org.joinmastodon.android.MastodonApp;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.CacheController;
import org.joinmastodon.android.api.DatabaseRunnable;
import org.joinmastodon.android.api.MastodonAPIController;
import org.joinmastodon.android.api.PushSubscriptionManager;
import org.joinmastodon.android.api.requests.accounts.GetOwnAccount;
import org.joinmastodon.android.api.requests.filters.GetLegacyFilters;
import org.joinmastodon.android.api.requests.instance.GetCustomEmojis;
import org.joinmastodon.android.api.requests.accounts.GetOwnAccount;
import org.joinmastodon.android.api.requests.instance.GetInstance;
import org.joinmastodon.android.api.requests.oauth.CreateOAuthApp;
import org.joinmastodon.android.events.EmojiUpdatedEvent;
@@ -30,9 +37,10 @@ import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.Application;
import org.joinmastodon.android.model.Emoji;
import org.joinmastodon.android.model.EmojiCategory;
import org.joinmastodon.android.model.LegacyFilter;
import org.joinmastodon.android.model.Instance;
import org.joinmastodon.android.model.LegacyFilter;
import org.joinmastodon.android.model.Token;
import org.joinmastodon.android.ui.utils.UiUtils;
import java.io.File;
import java.io.FileInputStream;
@@ -60,6 +68,7 @@ public class AccountSessionManager{
private static final String TAG="AccountSessionManager";
public static final String SCOPE="read write follow push";
public static final String REDIRECT_URI="mastodon-android-auth://callback";
private static final int DB_VERSION=1;
private static final AccountSessionManager instance=new AccountSessionManager();
@@ -73,6 +82,8 @@ public class AccountSessionManager{
private String lastActiveAccountID;
private SharedPreferences prefs;
private boolean loadedInstances;
private DatabaseHelper db;
private final Runnable databaseCloseRunnable=this::closeDatabase;
public static AccountSessionManager getInstance(){
return instance;
@@ -442,6 +453,68 @@ public class AccountSessionManager{
}
}
private void closeDelayed(){
CacheController.databaseThread.postRunnable(databaseCloseRunnable, 10_000);
}
public void closeDatabase(){
if(db!=null){
if(BuildConfig.DEBUG)
Log.d(TAG, "closeDatabase");
db.close();
db=null;
}
}
private void cancelDelayedClose(){
if(db!=null){
CacheController.databaseThread.handler.removeCallbacks(databaseCloseRunnable);
}
}
private SQLiteDatabase getOrOpenDatabase(){
if(db==null)
db=new DatabaseHelper();
return db.getWritableDatabase();
}
private void runOnDbThread(DatabaseRunnable r){
cancelDelayedClose();
CacheController.databaseThread.postRunnable(()->{
try{
SQLiteDatabase db=getOrOpenDatabase();
r.run(db);
}catch(SQLiteException|IOException x){
Log.w(TAG, x);
}finally{
closeDelayed();
}
}, 0);
}
public void runIfDonationCampaignNotDismissed(String id, Runnable action){
runOnDbThread(db->{
try(Cursor cursor=db.query("dismissed_donation_campaigns", null, "id=?", new String[]{id}, null, null, null)){
if(!cursor.moveToFirst()){
UiUtils.runOnUiThread(action);
}
}
});
}
public void markDonationCampaignAsDismissed(String id){
runOnDbThread(db->{
ContentValues values=new ContentValues();
values.put("id", id);
values.put("dismissed_at", System.currentTimeMillis());
db.insert("dismissed_donation_campaigns", null, values);
});
}
public void clearDismissedDonationCampaigns(){
runOnDbThread(db->db.delete("dismissed_donation_campaigns", null, null));
}
private static class SessionsStorageWrapper{
public List<AccountSession> accounts;
}
@@ -451,4 +524,24 @@ public class AccountSessionManager{
public List<Emoji> emojis;
public long lastUpdated;
}
private static class DatabaseHelper extends SQLiteOpenHelper{
public DatabaseHelper(){
super(MastodonApp.context, "accounts.db", null, DB_VERSION);
}
@Override
public void onCreate(SQLiteDatabase db){
db.execSQL("""
CREATE TABLE `dismissed_donation_campaigns` (
`id` text PRIMARY KEY,
`dismissed_at` bigint
)""");
}
@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion){
}
}
}

View File

@@ -0,0 +1,9 @@
package org.joinmastodon.android.events;
public class DismissDonationCampaignBannerEvent{
public final String campaignID;
public DismissDonationCampaignBannerEvent(String campaignID){
this.campaignID=campaignID;
}
}

View File

@@ -0,0 +1,52 @@
package org.joinmastodon.android.fragments;
import android.os.Bundle;
import android.view.View;
import android.webkit.WebResourceRequest;
import org.joinmastodon.android.E;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.events.DismissDonationCampaignBannerEvent;
import org.joinmastodon.android.ui.M3AlertDialogBuilder;
import java.util.Objects;
import me.grishka.appkit.Nav;
public class DonationWebViewFragment extends WebViewFragment{
public static final String SUCCESS_URL="https://sponsor.joinmastodon.org/donation/success";
public static final String FAILURE_URL="https://sponsor.joinmastodon.org/donation/failure";
@Override
public void onViewCreated(View view, Bundle savedInstanceState){
super.onViewCreated(view, savedInstanceState);
webView.loadUrl(Objects.requireNonNull(getArguments().getString("url")));
}
@Override
protected boolean shouldOverrideUrlLoading(WebResourceRequest req){
String url=req.getUrl().buildUpon().clearQuery().fragment(null).build().toString();
if(url.equalsIgnoreCase(SUCCESS_URL)){
new M3AlertDialogBuilder(getActivity())
.setTitle("Success")
.setMessage("Some sort of UI that would tell the user that their payment was successful")
.setPositiveButton(R.string.ok, null)
.setOnDismissListener(dlg->Nav.finish(this))
.show();
String campaignID=getArguments().getString("campaignID");
AccountSessionManager.getInstance().markDonationCampaignAsDismissed(campaignID);
E.post(new DismissDonationCampaignBannerEvent(campaignID));
return true;
}else if(url.equalsIgnoreCase(FAILURE_URL)){
new M3AlertDialogBuilder(getActivity())
.setTitle("Failure")
.setMessage("Some sort of UI that would tell the user that their payment didn't go through")
.setPositiveButton(R.string.ok, null)
.setOnDismissListener(dlg->Nav.finish(this))
.show();
return true;
}
return false;
}
}

View File

@@ -7,13 +7,19 @@ import android.animation.ObjectAnimator;
import android.app.Activity;
import android.content.res.Configuration;
import android.os.Bundle;
import android.text.SpannableStringBuilder;
import android.text.TextUtils;
import android.text.style.ForegroundColorSpan;
import android.text.style.TypefaceSpan;
import android.text.style.UnderlineSpan;
import android.view.Gravity;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewStub;
import android.view.ViewTreeObserver;
import android.view.accessibility.AccessibilityNodeInfo;
import android.view.animation.AnimationUtils;
import android.widget.Button;
@@ -29,11 +35,13 @@ import com.squareup.otto.Subscribe;
import org.joinmastodon.android.E;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.api.requests.catalog.GetDonationCampaigns;
import org.joinmastodon.android.api.requests.markers.SaveMarkers;
import org.joinmastodon.android.api.requests.timelines.GetHomeTimeline;
import org.joinmastodon.android.api.requests.timelines.GetListTimeline;
import org.joinmastodon.android.api.requests.timelines.GetPublicTimeline;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.events.DismissDonationCampaignBannerEvent;
import org.joinmastodon.android.events.SelfUpdateStateChangedEvent;
import org.joinmastodon.android.fragments.settings.SettingsMainFragment;
import org.joinmastodon.android.model.CacheablePaginatedResponse;
@@ -41,8 +49,10 @@ import org.joinmastodon.android.model.FilterContext;
import org.joinmastodon.android.model.FollowList;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.model.TimelineMarkers;
import org.joinmastodon.android.model.donations.DonationCampaign;
import org.joinmastodon.android.ui.displayitems.GapStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.StatusDisplayItem;
import org.joinmastodon.android.ui.sheets.DonationSheet;
import org.joinmastodon.android.ui.utils.DiscoverInfoBannerHelper;
import org.joinmastodon.android.ui.viewcontrollers.HomeTimelineMenuController;
import org.joinmastodon.android.ui.viewcontrollers.ToolbarDropdownMenuController;
@@ -53,6 +63,7 @@ import org.parceler.Parcels;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import androidx.annotation.NonNull;
@@ -81,9 +92,12 @@ public class HomeTimelineFragment extends StatusListFragment implements ToolbarD
private FollowList currentList;
private MergeRecyclerAdapter mergeAdapter;
private DiscoverInfoBannerHelper localTimelineBannerHelper;
private View donationBanner;
private boolean donationBannerDismissing;
private String maxID;
private String lastSavedMarkerID;
private DonationCampaign currentDonationCampaign;
public HomeTimelineFragment(){
setListLayoutId(R.layout.fragment_timeline);
@@ -93,6 +107,23 @@ public class HomeTimelineFragment extends StatusListFragment implements ToolbarD
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
localTimelineBannerHelper=new DiscoverInfoBannerHelper(DiscoverInfoBannerHelper.BannerType.LOCAL_TIMELINE, accountID);
// TODO how often do we do this request? Maybe cache something somewhere?
if(AccountSessionManager.get(accountID).isEligibleForDonations()){
new GetDonationCampaigns(Locale.getDefault().toLanguageTag().replace('-', '_'), String.valueOf(AccountSessionManager.get(accountID).getDonationSeed()))
.setCallback(new Callback<>(){
@Override
public void onSuccess(DonationCampaign result){
if(result==null)
return;
AccountSessionManager.getInstance().runIfDonationCampaignNotDismissed(result.id, ()->showDonationBanner(result));
}
@Override
public void onError(ErrorResponse error){}
})
.execNoAuth("");
}
}
@Override
@@ -599,6 +630,12 @@ public class HomeTimelineFragment extends StatusListFragment implements ToolbarD
updateUpdateState(ev.state);
}
public void onDismissDonationCampaignBanner(DismissDonationCampaignBannerEvent ev){
if(currentDonationCampaign!=null && ev.campaignID.equals(currentDonationCampaign.id)){
dismissDonationBanner();
}
}
@Override
protected boolean shouldRemoveAccountPostsWhenUnfollowing(){
return true;
@@ -661,6 +698,75 @@ public class HomeTimelineFragment extends StatusListFragment implements ToolbarD
};
}
private void showDonationBanner(DonationCampaign campaign){
if(getActivity()==null)
return;
currentDonationCampaign=campaign;
if(donationBanner==null){
ViewStub stub=contentView.findViewById(R.id.donation_banner);
donationBanner=stub.inflate();
donationBanner.findViewById(R.id.banner_dismiss).setOnClickListener(v->{
AccountSessionManager.getInstance().markDonationCampaignAsDismissed(currentDonationCampaign.id);
dismissDonationBanner();
});
donationBanner.setOnClickListener(v->openDonationSheet());
}else{
donationBanner.setVisibility(View.VISIBLE);
}
TextView text=donationBanner.findViewById(R.id.banner_text);
SpannableStringBuilder ssb=new SpannableStringBuilder(campaign.bannerMessage);
ssb.append(' ');
int start=ssb.length();
ssb.append(campaign.bannerButtonText);
ssb.setSpan(new ForegroundColorSpan(getResources().getColor(R.color.masterialDark_colorGoldenrodContainer, getActivity().getTheme())), start, ssb.length(), 0);
ssb.setSpan(new UnderlineSpan(), start, ssb.length(), 0);
ssb.setSpan(new TypefaceSpan("sans-serif-medium"), start, ssb.length(), 0);
text.setText(ssb);
donationBanner.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener(){
@Override
public boolean onPreDraw(){
donationBanner.getViewTreeObserver().removeOnPreDrawListener(this);
AnimatorSet set=new AnimatorSet();
set.playTogether(
ObjectAnimator.ofFloat(donationBanner, View.TRANSLATION_Y, donationBanner.getHeight(), 0),
ObjectAnimator.ofFloat(fab, View.TRANSLATION_Y, -donationBanner.getHeight())
);
set.setDuration(250);
set.setInterpolator(CubicBezierInterpolator.DEFAULT);
set.start();
return true;
}
});
}
private void dismissDonationBanner(){
if(donationBanner==null || donationBannerDismissing)
return;
AnimatorSet set=new AnimatorSet();
set.playTogether(
ObjectAnimator.ofFloat(donationBanner, View.TRANSLATION_Y, donationBanner.getHeight()),
ObjectAnimator.ofFloat(fab, View.TRANSLATION_Y, 0)
);
set.setDuration(250);
set.setInterpolator(CubicBezierInterpolator.DEFAULT);
set.addListener(new AnimatorListenerAdapter(){
@Override
public void onAnimationEnd(Animator animation){
donationBanner.setVisibility(View.GONE);
donationBannerDismissing=false;
}
});
donationBannerDismissing=true;
set.start();
currentDonationCampaign=null;
}
private void openDonationSheet(){
new DonationSheet(getActivity(), currentDonationCampaign, accountID).show();
}
private enum ListMode{
FOLLOWING,
LOCAL,

View File

@@ -0,0 +1,76 @@
package org.joinmastodon.android.fragments;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.webkit.WebChromeClient;
import android.webkit.WebResourceError;
import android.webkit.WebResourceRequest;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import org.joinmastodon.android.api.MastodonErrorResponse;
import me.grishka.appkit.Nav;
import me.grishka.appkit.fragments.LoaderFragment;
import me.grishka.appkit.fragments.OnBackPressedListener;
public abstract class WebViewFragment extends LoaderFragment implements OnBackPressedListener{
protected WebView webView;
@Override
public View onCreateContentView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState){
webView=new WebView(getActivity());
webView.setWebChromeClient(new WebChromeClient(){
@Override
public void onReceivedTitle(WebView view, String title){
setTitle(title);
}
});
webView.setWebViewClient(new WebViewClient(){
@Override
public void onPageFinished(WebView view, String url){
dataLoaded();
}
@Override
public void onReceivedError(WebView view, WebResourceRequest request, WebResourceError error){
onError(new MastodonErrorResponse(error.getDescription().toString(), -1, null));
}
@Override
public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request){
return WebViewFragment.this.shouldOverrideUrlLoading(request);
}
});
webView.getSettings().setJavaScriptEnabled(true);
return webView;
}
@Override
protected void doLoadData(){
}
@Override
public void onRefresh(){
webView.reload();
}
@Override
public boolean onBackPressed(){
if(webView.canGoBack()){
webView.goBack();
return true;
}
return false;
}
@Override
public void onToolbarNavigationClick(){
Nav.finish(this);
}
protected abstract boolean shouldOverrideUrlLoading(WebResourceRequest req);
}

View File

@@ -28,7 +28,8 @@ public class SettingsDebugFragment extends BaseSettingsFragment<Void>{
selfUpdateItem=new ListItem<>("Force self-update", null, this::onForceSelfUpdateClick),
resetUpdateItem=new ListItem<>("Reset self-updater", null, this::onResetUpdaterClick),
new ListItem<>("Reset search info banners", null, this::onResetDiscoverBannersClick),
new ListItem<>("Reset pre-reply sheets", null, this::onResetPreReplySheetsClick)
new ListItem<>("Reset pre-reply sheets", null, this::onResetPreReplySheetsClick),
new ListItem<>("Clear dismissed donation campaigns", null, this::onClearDismissedCampaignsClick)
));
if(!GithubSelfUpdater.needSelfUpdating()){
resetUpdateItem.isEnabled=selfUpdateItem.isEnabled=false;
@@ -70,6 +71,11 @@ public class SettingsDebugFragment extends BaseSettingsFragment<Void>{
Toast.makeText(getActivity(), "Pre-reply sheets were reset", Toast.LENGTH_SHORT).show();
}
private void onClearDismissedCampaignsClick(ListItem<?> item){
AccountSessionManager.getInstance().clearDismissedDonationCampaigns();
Toast.makeText(getActivity(), "Dismissed campaigns cleared. Restart app to see your current campaign, if any", Toast.LENGTH_LONG).show();
}
private void restartUI(){
Bundle args=new Bundle();
args.putString("account", accountID);

View File

@@ -0,0 +1,33 @@
package org.joinmastodon.android.model.donations;
import org.joinmastodon.android.api.AllFieldsAreRequired;
import org.joinmastodon.android.api.ObjectValidationException;
import org.joinmastodon.android.api.RequiredField;
import org.joinmastodon.android.model.BaseModel;
import java.util.Map;
@AllFieldsAreRequired
public class DonationCampaign extends BaseModel{
public String id;
public String bannerMessage;
public String bannerButtonText;
public String donationMessage;
public String donationButtonText;
public Amounts amounts;
public String defaultCurrency;
public String donationUrl;
@Override
public void postprocess() throws ObjectValidationException{
super.postprocess();
amounts.postprocess();
}
public static class Amounts extends BaseModel{
public Map<String, long[]> oneTime;
@RequiredField
public Map<String, long[]> monthly;
public Map<String, long[]> yearly;
}
}

View File

@@ -0,0 +1,288 @@
package org.joinmastodon.android.ui.sheets;
import android.app.Activity;
import android.content.Context;
import android.graphics.drawable.ColorDrawable;
import android.net.Uri;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.Window;
import android.view.WindowManager;
import android.widget.LinearLayout;
import android.widget.TextView;
import android.widget.Toast;
import android.widget.ToggleButton;
import org.joinmastodon.android.R;
import org.joinmastodon.android.fragments.DonationWebViewFragment;
import org.joinmastodon.android.model.donations.DonationCampaign;
import org.joinmastodon.android.ui.M3AlertDialogBuilder;
import org.joinmastodon.android.ui.OutlineProviders;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.ui.views.CurrencyAmountInput;
import java.text.NumberFormat;
import java.util.Currency;
import java.util.List;
import java.util.Locale;
import java.util.stream.Collectors;
import androidx.annotation.NonNull;
import me.grishka.appkit.Nav;
import me.grishka.appkit.utils.V;
import me.grishka.appkit.views.BottomSheet;
public class DonationSheet extends BottomSheet{
private final DonationCampaign campaign;
private final String accountID;
private DonationFrequency frequency=DonationFrequency.MONTHLY;
private View onceTab, monthlyTab, yearlyTab;
private int currentTab;
private CurrencyAmountInput amountField;
private ToggleButton[] suggestedAmountButtons=new ToggleButton[6];
private View button;
private TextView buttonText;
private Activity activity;
public DonationSheet(@NonNull Activity activity, DonationCampaign campaign, String accountID){
super(activity);
this.campaign=campaign;
this.accountID=accountID;
this.activity=activity;
Context context=activity;
View content=context.getSystemService(LayoutInflater.class).inflate(R.layout.sheet_donation, null);
setContentView(content);
setNavigationBarBackground(new ColorDrawable(UiUtils.alphaBlendColors(UiUtils.getThemeColor(context, R.attr.colorM3Surface),
UiUtils.getThemeColor(context, R.attr.colorM3Primary), 0.05f)), !UiUtils.isDarkTheme());
TextView text=findViewById(R.id.text);
text.setText(campaign.donationMessage);
onceTab=findViewById(R.id.once);
monthlyTab=findViewById(R.id.monthly);
yearlyTab=findViewById(R.id.yearly);
onceTab.setOnClickListener(this::onTabClick);
monthlyTab.setOnClickListener(this::onTabClick);
yearlyTab.setOnClickListener(this::onTabClick);
if(campaign.amounts.yearly==null)
yearlyTab.setVisibility(View.GONE);
if(campaign.amounts.oneTime==null)
onceTab.setVisibility(View.GONE);
if(campaign.amounts.monthly==null){
monthlyTab.setVisibility(View.GONE);
if(campaign.amounts.oneTime!=null){
onceTab.setSelected(true);
currentTab=R.id.once;
frequency=DonationFrequency.ONCE;
}else if(campaign.amounts.yearly!=null){
yearlyTab.setSelected(true);
currentTab=R.id.yearly;
frequency=DonationFrequency.YEARLY;
}else{
Toast.makeText(context, "Amounts object is empty", Toast.LENGTH_SHORT).show();
dismiss();
return;
}
}else{
monthlyTab.setSelected(true);
currentTab=R.id.monthly;
}
View tabBarItself=findViewById(R.id.tabbar_inner);
tabBarItself.setOutlineProvider(OutlineProviders.roundedRect(20));
tabBarItself.setClipToOutline(true);
amountField=findViewById(R.id.amount);
List<String> availableCurrencies=campaign.amounts.monthly.keySet().stream().sorted().collect(Collectors.toList());
amountField.setCurrencies(availableCurrencies);
try{
amountField.setSelectedCurrency(campaign.defaultCurrency);
}catch(IllegalArgumentException x){
new M3AlertDialogBuilder(context)
.setTitle(R.string.error)
.setMessage("Default currency "+campaign.defaultCurrency+" not in list of available currencies "+availableCurrencies)
.show();
dismiss();
return;
}
amountField.setChangeListener(new CurrencyAmountInput.ChangeListener(){
@Override
public void onCurrencyChanged(String code){
updateSuggestedAmounts(code);
button.setEnabled(amountField.getAmount()>=getMinimumChargeAmount(code));
updateSuggestedButtonsState();
}
@Override
public void onAmountChanged(long amount){
button.setEnabled(amount>=getMinimumChargeAmount(amountField.getCurrency()));
updateSuggestedButtonsState();
}
});
button=findViewById(R.id.button);
buttonText=findViewById(R.id.button_text);
LinearLayout suggestedAmounts=findViewById(R.id.suggested_amounts);
for(int i=0;i<suggestedAmountButtons.length;i++){
ToggleButton btn=new ToggleButton(context);
btn.setBackgroundResource(R.drawable.bg_filter_chip);
btn.setTextAppearance(R.style.m3_label_large);
btn.setTextColor(context.getResources().getColorStateList(R.color.filter_chip_text, context.getTheme()));
btn.setMinWidth(V.dp(64));
btn.setMinimumWidth(0);
int pad=V.dp(16);
btn.setPadding(pad, 0, pad, 0);
btn.setStateListAnimator(null);
btn.setTextOff(null);
btn.setTextOn(null);
btn.setOnClickListener(this::onSuggestedAmountClick);
btn.setTag(i);
suggestedAmountButtons[i]=btn;
LinearLayout.LayoutParams lp=new LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.MATCH_PARENT);
lp.rightMargin=V.dp(8);
suggestedAmounts.addView(btn, lp);
}
updateSuggestedAmounts(campaign.defaultCurrency);
button.setEnabled(false);
buttonText.setText(campaign.bannerButtonText);
button.setOnClickListener(v->openWebView());
}
@Override
protected void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
Window window=getWindow();
window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE);
}
private void onTabClick(View v){
if(v.getId()==currentTab)
return;
findViewById(currentTab).setSelected(false);
v.setSelected(true);
currentTab=v.getId();
if(currentTab==R.id.once)
frequency=DonationFrequency.ONCE;
else if(currentTab==R.id.monthly)
frequency=DonationFrequency.MONTHLY;
else if(currentTab==R.id.yearly)
frequency=DonationFrequency.YEARLY;
updateSuggestedAmounts(amountField.getCurrency());
}
private long[] getCurrentSuggestedAmounts(String currency){
long[] amounts=(switch(frequency){
case ONCE -> campaign.amounts.oneTime;
case MONTHLY -> campaign.amounts.monthly;
case YEARLY -> campaign.amounts.yearly;
}).get(currency);
if(amounts==null){
amounts=new long[0];
}
return amounts;
}
private void updateSuggestedAmounts(String currency){
NumberFormat format=NumberFormat.getCurrencyInstance();
try{
format.setCurrency(Currency.getInstance(currency));
}catch(IllegalArgumentException ignore){}
int defaultFractionDigits=format.getMinimumFractionDigits();
long[] amounts=getCurrentSuggestedAmounts(currency);
for(int i=0;i<suggestedAmountButtons.length;i++){
ToggleButton btn=suggestedAmountButtons[i];
if(i>=amounts.length){
btn.setVisibility(View.GONE);
continue;
}
btn.setVisibility(View.VISIBLE);
long amount=amounts[i];
format.setMinimumFractionDigits(amount%100==0 ? 0 : defaultFractionDigits);
btn.setText(format.format(amount/100.0));
}
updateSuggestedButtonsState();
}
private void onSuggestedAmountClick(View v){
int index=(int) v.getTag();
long[] amounts=getCurrentSuggestedAmounts(amountField.getCurrency());
amountField.setAmount(amounts[index]);
}
private void updateSuggestedButtonsState(){
long amount=amountField.getAmount();
long[] amounts=getCurrentSuggestedAmounts(amountField.getCurrency());
for(int i=0;i<Math.min(amounts.length, suggestedAmountButtons.length);i++){
ToggleButton btn=suggestedAmountButtons[i];
btn.setChecked(amounts[i]==amount);
}
}
private void openWebView(){
Uri.Builder builder=Uri.parse(campaign.donationUrl).buildUpon();
builder.appendQueryParameter("locale", Locale.getDefault().toLanguageTag().replace('-', '_'))
.appendQueryParameter("platform", "android")
.appendQueryParameter("currency", amountField.getCurrency())
.appendQueryParameter("amount", String.valueOf(amountField.getAmount()))
.appendQueryParameter("source", "campaign")
.appendQueryParameter("campaign_id", campaign.id)
.appendQueryParameter("frequency", switch(frequency){
case ONCE -> "one_time";
case MONTHLY -> "monthly";
case YEARLY -> "yearly";
})
.appendQueryParameter("success_callback_url", DonationWebViewFragment.SUCCESS_URL)
.appendQueryParameter("failure_callback_url", DonationWebViewFragment.FAILURE_URL);
Bundle args=new Bundle();
args.putString("url", builder.build().toString());
args.putString("account", accountID);
args.putString("campaignID", campaign.id);
Nav.go(activity, DonationWebViewFragment.class, args);
dismiss();
}
private static long getMinimumChargeAmount(String currency){
// https://docs.stripe.com/currencies#minimum-and-maximum-charge-amounts
// values are in cents
return switch(currency){
case "USD" -> 50;
case "AED" -> 2_00;
case "AUD" -> 50;
case "BGN" -> 1_00;
case "BRL" -> 50;
case "CAD" -> 50;
case "CHF" -> 50;
case "CZK" -> 15_00;
case "DKK" -> 2_50;
case "EUR" -> 50;
case "GBP" -> 30;
case "HKD" -> 4_00;
case "HUF" -> 175_00;
case "INR" -> 50;
case "JPY" -> 50_00;
case "MXN" -> 10_00;
case "MYR" -> 2_00;
case "NOK" -> 3_00;
case "NZD" -> 50;
case "PLN" -> 2_00;
case "RON" -> 2_00;
case "SEK" -> 3_00;
case "SGD" -> 50;
case "THB" -> 10_00;
default -> 50;
};
}
private enum DonationFrequency{
ONCE,
MONTHLY,
YEARLY
}
}

View File

@@ -0,0 +1,336 @@
package org.joinmastodon.android.ui.views;
import android.annotation.SuppressLint;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.text.Editable;
import android.text.InputFilter;
import android.text.InputType;
import android.text.Spannable;
import android.text.SpannableString;
import android.text.Spanned;
import android.text.TextWatcher;
import android.text.method.DigitsKeyListener;
import android.text.style.ReplacementSpan;
import android.util.AttributeSet;
import android.view.Gravity;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ArrayAdapter;
import android.widget.Button;
import android.widget.EditText;
import android.widget.LinearLayout;
import android.widget.TextView;
import org.joinmastodon.android.R;
import org.joinmastodon.android.ui.M3AlertDialogBuilder;
import org.joinmastodon.android.ui.OutlineProviders;
import org.joinmastodon.android.ui.utils.UiUtils;
import java.text.NumberFormat;
import java.text.ParseException;
import java.util.Currency;
import java.util.List;
import java.util.stream.Collectors;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import me.grishka.appkit.utils.CustomViewHelper;
public class CurrencyAmountInput extends LinearLayout implements CustomViewHelper{
private ActualEditText edit;
private Button currencyBtn;
private List<CurrencyInfo> currencies;
private CurrencyInfo currentCurrency;
private boolean spanAdded;
private CurrencySymbolSpan symbolSpan;
private boolean symbolBeforeAmount;
private ChangeListener changeListener;
private long lastAmount=0;
private NumberFormat numberFormat=NumberFormat.getNumberInstance();
private boolean allowSymbolToBeDeleted;
public CurrencyAmountInput(Context context){
this(context, null);
}
public CurrencyAmountInput(Context context, AttributeSet attrs){
this(context, attrs, 0);
}
public CurrencyAmountInput(Context context, AttributeSet attrs, int defStyle){
super(context, attrs, defStyle);
setForeground(getResources().getDrawable(R.drawable.fg_currency_input, context.getTheme()));
setAddStatesFromChildren(true);
if(!isInEditMode())
setOutlineProvider(OutlineProviders.roundedRect(8));
setClipToOutline(true);
currencyBtn=new Button(context);
currencyBtn.setTextAppearance(R.style.m3_label_large);
currencyBtn.setSingleLine();
currencyBtn.setTextColor(UiUtils.getThemeColor(context, R.attr.colorM3OnSurfaceVariant));
int pad=dp(12);
currencyBtn.setPadding(pad, 0, pad, 0);
currencyBtn.setBackgroundColor(UiUtils.getThemeColor(context, R.attr.colorM3SurfaceVariant));
currencyBtn.setMinimumWidth(0);
currencyBtn.setMinWidth(0);
currencyBtn.setOnClickListener(v->showCurrencySelector());
addView(currencyBtn, new LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.MATCH_PARENT));
edit=new ActualEditText(context);
edit.setBackgroundColor(UiUtils.getThemeColor(context, R.attr.colorM3Surface));
pad=dp(16);
edit.setPadding(pad, 0, pad, 0);
edit.setSingleLine();
edit.setTextAppearance(R.style.m3_title_large);
edit.setTextColor(UiUtils.getThemeColor(context, R.attr.colorM3OnSurface));
edit.setGravity(Gravity.END |Gravity.CENTER_VERTICAL);
edit.setInputType(InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_DECIMAL);
InputFilter[] filters=edit.getText().getFilters();
for(int i=0;i<filters.length;i++){
if(filters[i] instanceof DigitsKeyListener){
filters[i]=new FormattingFriendlyDigitsKeyListener();
edit.getText().setFilters(filters);
break;
}
}
addView(edit, new LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 1f));
symbolSpan=new CurrencySymbolSpan(edit.getPaint());
NumberFormat format=NumberFormat.getInstance();
String one=format.format(1);
format=NumberFormat.getCurrencyInstance();
format.setCurrency(Currency.getInstance("USD"));
symbolBeforeAmount=format.format(1).indexOf(one)>0;
edit.addTextChangedListener(new TextWatcher(){
private boolean ignore;
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after){
}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count){
}
@Override
public void afterTextChanged(Editable e){
if(ignore)
return;
ignore=true;
if(e.length()>0 && !spanAdded){
SpannableString ss=new SpannableString(" ");
ss.setSpan(symbolSpan, 0, 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
if(symbolBeforeAmount)
e.insert(0, ss);
else
e.append(ss);
spanAdded=true;
}else if(spanAdded && e.length()<=1){
spanAdded=false;
if(e.length()>0){
allowSymbolToBeDeleted=true;
e.clear();
allowSymbolToBeDeleted=false;
}
}
ignore=false;
updateAmount();
}
});
}
public void setCurrencies(List<String> currencies){
this.currencies=currencies.stream().map(CurrencyInfo::new).collect(Collectors.toList());
}
public void setSelectedCurrency(String code){
CurrencyInfo info=null;
for(CurrencyInfo c:currencies){
if(c.code.equals(code)){
info=c;
break;
}
}
if(info==null)
throw new IllegalArgumentException();
setCurrency(info);
}
private void setCurrency(CurrencyInfo info){
currencyBtn.setText(info.code);
currentCurrency=info;
edit.invalidate();
if(changeListener!=null)
changeListener.onCurrencyChanged(info.code);
}
private void showCurrencySelector(){
ArrayAdapter<CurrencyInfo> adapter=new ArrayAdapter<>(getContext(), R.layout.item_alert_single_choice_2lines_but_different, R.id.text, currencies){
@Override
public boolean hasStableIds(){
return true;
}
@NonNull
@Override
public View getView(int position, @Nullable View convertView, @NonNull ViewGroup parent){
View view=super.getView(position, convertView, parent);
TextView subtitle=view.findViewById(R.id.subtitle);
CurrencyInfo item=getItem(position);
if(item.jCurrency==null || item.jCurrency.getDisplayName().equals(item.code)){
subtitle.setVisibility(View.GONE);
}else{
subtitle.setVisibility(View.VISIBLE);
subtitle.setText(item.jCurrency.getDisplayName());
}
return view;
}
};
new M3AlertDialogBuilder(getContext())
.setTitle(R.string.currency)
.setSingleChoiceItems(adapter, currencies.indexOf(currentCurrency), (dlg, item)->{
setCurrency(currencies.get(item));
dlg.dismiss();
})
.show();
}
public void setChangeListener(ChangeListener changeListener){
this.changeListener=changeListener;
}
private void updateAmount(){
long newAmount;
try{
Number n=numberFormat.parse(edit.getText().toString().trim());
if(n instanceof Long l){
newAmount=l*100L;
}else if(n instanceof Double d){
newAmount=(long)(d*100);
}else{
newAmount=0;
}
}catch(ParseException x){
newAmount=0;
}
if(newAmount!=lastAmount){
lastAmount=newAmount;
if(changeListener!=null)
changeListener.onAmountChanged(lastAmount);
}
}
public long getAmount(){
return lastAmount;
}
public String getCurrency(){
return currentCurrency.code;
}
@SuppressLint("DefaultLocale")
public void setAmount(long amount){
String value;
if(amount%100==0)
value=String.valueOf(amount/100);
else
value=String.format("%.2f", amount/100.0);
int start=spanAdded ? 1 : 0;
edit.getText().replace(start, edit.length(), value);
}
private class ActualEditText extends EditText{
public ActualEditText(Context context){
super(context);
setClipToPadding(false);
}
@Override
protected void onSelectionChanged(int selStart, int selEnd){
super.onSelectionChanged(selStart, selEnd);
// Adjust the selection to prevent the symbol span being selected
if(spanAdded){
int newSelStart=symbolBeforeAmount ? Math.max(selStart, 1) : Math.min(selStart, length()-1);
int newSelEnd=symbolBeforeAmount ? Math.max(selEnd, 1) : Math.min(selEnd, length()-1);
if(newSelStart!=selStart || newSelEnd!=selEnd){
setSelection(newSelStart, newSelEnd);
}
}
}
}
private static class CurrencyInfo{
public String code;
public String symbol;
public Currency jCurrency;
public CurrencyInfo(String code){
this.code=code;
try{
jCurrency=Currency.getInstance(code);
symbol=jCurrency.getSymbol();
}catch(IllegalArgumentException x){
symbol=code;
}
}
@NonNull
@Override
public String toString(){
return code;
}
}
private class CurrencySymbolSpan extends ReplacementSpan{
private Paint paint;
public CurrencySymbolSpan(Paint paint){
this.paint=new Paint(paint);
this.paint.setTextSize(paint.getTextSize()*0.66f);
this.paint.setAlpha(77);
}
@Override
public int getSize(@NonNull Paint paint, CharSequence text, int start, int end, @Nullable Paint.FontMetricsInt fm){
return Math.round(this.paint.measureText(currentCurrency.symbol))+dp(2);
}
@Override
public void draw(@NonNull Canvas canvas, CharSequence text, int start, int end, float x, int top, int y, int bottom, @NonNull Paint paint){
if(!symbolBeforeAmount)
x+=dp(2);
canvas.drawText(currentCurrency.symbol, x, top+dp(1.5f)-this.paint.ascent(), this.paint);
}
}
private class FormattingFriendlyDigitsKeyListener extends DigitsKeyListener{
public FormattingFriendlyDigitsKeyListener(){
super(false, true);
}
@Override
public CharSequence filter(CharSequence source, int start, int end, Spanned dest, int dstart, int dend){
// Allow the currency symbol to be inserted (always done as a separate insertion operation)
if(source instanceof Spannable s && s.getSpans(start, end, CurrencySymbolSpan.class).length>0){
return source;
}
// Don't allow the currency symbol to be deleted
if(!allowSymbolToBeDeleted && end-start<dend-dstart && dest.getSpans(dstart, dend, CurrencySymbolSpan.class).length>0){
return dest.subSequence(dstart, dend);
}
return super.filter(source, start, end, dest, dstart, dend);
}
}
public interface ChangeListener{
void onCurrencyChanged(String code);
void onAmountChanged(long amount);
}
}