diff --git a/mastodon/build.gradle b/mastodon/build.gradle index 5077aae0..c7148d80 100644 --- a/mastodon/build.gradle +++ b/mastodon/build.gradle @@ -13,7 +13,7 @@ android { applicationId "org.joinmastodon.android" minSdk 23 targetSdk 33 - versionCode 93 + versionCode 94 versionName "2.5.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/CacheController.java b/mastodon/src/main/java/org/joinmastodon/android/api/CacheController.java index 6e226bb6..3a027e7e 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/CacheController.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/CacheController.java @@ -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; - } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/DatabaseRunnable.java b/mastodon/src/main/java/org/joinmastodon/android/api/DatabaseRunnable.java new file mode 100644 index 00000000..bf8b711c --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/DatabaseRunnable.java @@ -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; +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/catalog/GetDonationCampaigns.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/catalog/GetDonationCampaigns.java new file mode 100644 index 00000000..e6f018ef --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/catalog/GetDonationCampaigns.java @@ -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{ + 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(); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSession.java b/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSession.java index 43190c73..55cdac05 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSession.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSession.java @@ -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; + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSessionManager.java b/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSessionManager.java index 07e5059d..621c5a19 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSessionManager.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSessionManager.java @@ -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 accounts; } @@ -451,4 +524,24 @@ public class AccountSessionManager{ public List 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){ + + } + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/events/DismissDonationCampaignBannerEvent.java b/mastodon/src/main/java/org/joinmastodon/android/events/DismissDonationCampaignBannerEvent.java new file mode 100644 index 00000000..101369e1 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/events/DismissDonationCampaignBannerEvent.java @@ -0,0 +1,9 @@ +package org.joinmastodon.android.events; + +public class DismissDonationCampaignBannerEvent{ + public final String campaignID; + + public DismissDonationCampaignBannerEvent(String campaignID){ + this.campaignID=campaignID; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/DonationWebViewFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/DonationWebViewFragment.java new file mode 100644 index 00000000..6ca545c4 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/DonationWebViewFragment.java @@ -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; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTimelineFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTimelineFragment.java index 174aa233..1d60f042 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTimelineFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTimelineFragment.java @@ -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, diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/WebViewFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/WebViewFragment.java new file mode 100644 index 00000000..6146abc7 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/WebViewFragment.java @@ -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); +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsDebugFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsDebugFragment.java index 755238e8..35b9554d 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsDebugFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsDebugFragment.java @@ -28,7 +28,8 @@ public class SettingsDebugFragment extends BaseSettingsFragment{ 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{ 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); diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/donations/DonationCampaign.java b/mastodon/src/main/java/org/joinmastodon/android/model/donations/DonationCampaign.java new file mode 100644 index 00000000..819011d0 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/model/donations/DonationCampaign.java @@ -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 oneTime; + @RequiredField + public Map monthly; + public Map yearly; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/sheets/DonationSheet.java b/mastodon/src/main/java/org/joinmastodon/android/ui/sheets/DonationSheet.java new file mode 100644 index 00000000..4fa4ab52 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/sheets/DonationSheet.java @@ -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 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;iopenWebView()); + } + + @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=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 "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 + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/views/CurrencyAmountInput.java b/mastodon/src/main/java/org/joinmastodon/android/ui/views/CurrencyAmountInput.java new file mode 100644 index 00000000..bbd98310 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/views/CurrencyAmountInput.java @@ -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 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;i0; + + 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 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 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-start0){ + 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); + } +} diff --git a/mastodon/src/main/res/drawable-xxxhdpi/scribble.webp b/mastodon/src/main/res/drawable-xxxhdpi/scribble.webp new file mode 100644 index 00000000..abfe4d27 Binary files /dev/null and b/mastodon/src/main/res/drawable-xxxhdpi/scribble.webp differ diff --git a/mastodon/src/main/res/drawable/bg_donation_banner.xml b/mastodon/src/main/res/drawable/bg_donation_banner.xml new file mode 100644 index 00000000..de2ddedd --- /dev/null +++ b/mastodon/src/main/res/drawable/bg_donation_banner.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/bg_filter_chip.xml b/mastodon/src/main/res/drawable/bg_filter_chip.xml index d1136625..3072547e 100644 --- a/mastodon/src/main/res/drawable/bg_filter_chip.xml +++ b/mastodon/src/main/res/drawable/bg_filter_chip.xml @@ -1,5 +1,15 @@ + + + + + + + + + + diff --git a/mastodon/src/main/res/drawable/fg_currency_input.xml b/mastodon/src/main/res/drawable/fg_currency_input.xml new file mode 100644 index 00000000..13f79a32 --- /dev/null +++ b/mastodon/src/main/res/drawable/fg_currency_input.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/ic_donation_monthly.xml b/mastodon/src/main/res/drawable/ic_donation_monthly.xml new file mode 100644 index 00000000..2e796f13 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_donation_monthly.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/ic_favorite_18px.xml b/mastodon/src/main/res/drawable/ic_favorite_18px.xml new file mode 100644 index 00000000..c4066a42 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_favorite_18px.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_volunteer_activism_20px.xml b/mastodon/src/main/res/drawable/ic_volunteer_activism_20px.xml new file mode 100644 index 00000000..5f252b53 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_volunteer_activism_20px.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/layout/donation_banner.xml b/mastodon/src/main/res/layout/donation_banner.xml new file mode 100644 index 00000000..c25e4be1 --- /dev/null +++ b/mastodon/src/main/res/layout/donation_banner.xml @@ -0,0 +1,32 @@ + + + + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/layout/fragment_timeline.xml b/mastodon/src/main/res/layout/fragment_timeline.xml index defa53bc..e39c2057 100644 --- a/mastodon/src/main/res/layout/fragment_timeline.xml +++ b/mastodon/src/main/res/layout/fragment_timeline.xml @@ -58,5 +58,12 @@ android:text="@string/see_new_posts"/> + + \ No newline at end of file diff --git a/mastodon/src/main/res/layout/sheet_donation.xml b/mastodon/src/main/res/layout/sheet_donation.xml new file mode 100644 index 00000000..05cb1085 --- /dev/null +++ b/mastodon/src/main/res/layout/sheet_donation.xml @@ -0,0 +1,114 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/values/colors_masterial.xml b/mastodon/src/main/res/values/colors_masterial.xml new file mode 100644 index 00000000..4221dff3 --- /dev/null +++ b/mastodon/src/main/res/values/colors_masterial.xml @@ -0,0 +1,336 @@ + + + + #5E5791 + #FFFFFF + #E5DEFF + #1A1249 + #5F5C71 + #FFFFFF + #E5DFF9 + #1C192C + #7B5265 + #FFFFFF + #FFD8E7 + #301121 + #BA1A1A + #FFFFFF + #FFDAD6 + #410002 + #FCF8FF + #1C1B20 + #FCF8FF + #1C1B20 + #E5E0EC + #47464F + #78767F + #C9C5D0 + #000000 + #313036 + #F4EFF7 + #C7BFFF + #E5DEFF + #1A1249 + #C7BFFF + #463F77 + #E5DFF9 + #1C192C + #C8C3DC + #474459 + #FFD8E7 + #301121 + #ECB8CE + #613B4D + #DDD8E0 + #FCF8FF + #FFFFFF + #F7F2FA + #F1ECF4 + #EBE6EF + #E5E1E9 + #423B73 + #FFFFFF + #746DA9 + #FFFFFF + #434055 + #FFFFFF + #767288 + #FFFFFF + #5D3749 + #FFFFFF + #94687B + #FFFFFF + #8C0009 + #FFFFFF + #DA342E + #FFFFFF + #FCF8FF + #1C1B20 + #FCF8FF + #1C1B20 + #E5E0EC + #44424B + #605E67 + #7C7983 + #000000 + #313036 + #F4EFF7 + #C7BFFF + #746DA9 + #FFFFFF + #5C558E + #FFFFFF + #767288 + #FFFFFF + #5D596F + #FFFFFF + #94687B + #FFFFFF + #795063 + #FFFFFF + #DDD8E0 + #FCF8FF + #FFFFFF + #F7F2FA + #F1ECF4 + #EBE6EF + #E5E1E9 + #211950 + #FFFFFF + #423B73 + #FFFFFF + #222033 + #FFFFFF + #434055 + #FFFFFF + #381728 + #FFFFFF + #5D3749 + #FFFFFF + #4E0002 + #FFFFFF + #8C0009 + #FFFFFF + #FCF8FF + #1C1B20 + #FCF8FF + #000000 + #E5E0EC + #24232B + #44424B + #44424B + #000000 + #313036 + #FFFFFF + #EFE9FF + #423B73 + #FFFFFF + #2C255B + #FFFFFF + #434055 + #FFFFFF + #2D2A3E + #FFFFFF + #5D3749 + #FFFFFF + #442233 + #FFFFFF + #DDD8E0 + #FCF8FF + #FFFFFF + #F7F2FA + #F1ECF4 + #EBE6EF + #E5E1E9 + #7A590C + #FFFFFF + #FFDEA6 + #271900 + #4F6628 + #FFFFFF + #D1ECA0 + #121F00 + #583E00 + #FFFFFF + #936F23 + #FFFFFF + #34490F + #FFFFFF + #657C3C + #FFFFFF + #2F1F00 + #FFFFFF + #583E00 + #FFFFFF + #182600 + #FFFFFF + #34490F + #FFFFFF + + + #C7BFFF + #2F285F + #463F77 + #E5DEFF + #C8C3DC + #312E41 + #474459 + #E5DFF9 + #ECB8CE + #482536 + #613B4D + #FFD8E7 + #FFB4AB + #690005 + #93000A + #FFDAD6 + #141318 + #E5E1E9 + #141318 + #E5E1E9 + #47464F + #C9C5D0 + #928F99 + #47464F + #000000 + #E5E1E9 + #313036 + #5E5791 + #E5DEFF + #1A1249 + #C7BFFF + #463F77 + #E5DFF9 + #1C192C + #C8C3DC + #474459 + #FFD8E7 + #301121 + #ECB8CE + #613B4D + #141318 + #3A383E + #0E0E13 + #1C1B20 + #201F25 + #2A292F + #35343A + #CCC4FF + #150B44 + #9189C7 + #000000 + #CDC8E1 + #161426 + #928EA5 + #000000 + #F1BCD2 + #2A0B1C + #B28498 + #000000 + #FFBAB1 + #370001 + #FF5449 + #000000 + #141318 + #E5E1E9 + #141318 + #FEF9FF + #47464F + #CDC9D4 + #A5A1AC + #85828C + #000000 + #E5E1E9 + #2A292F + #474179 + #E5DEFF + #0F053F + #C7BFFF + #352E65 + #E5DFF9 + #110F21 + #C8C3DC + #363447 + #FFD8E7 + #230716 + #ECB8CE + #4F2B3C + #141318 + #3A383E + #0E0E13 + #1C1B20 + #201F25 + #2A292F + #35343A + #FEF9FF + #000000 + #CCC4FF + #000000 + #FEF9FF + #000000 + #CDC8E1 + #000000 + #FFF9F9 + #000000 + #F1BCD2 + #000000 + #FFF9F9 + #000000 + #FFBAB1 + #000000 + #141318 + #E5E1E9 + #141318 + #FFFFFF + #47464F + #FEF9FF + #CDC9D4 + #CDC9D4 + #000000 + #E5E1E9 + #000000 + #292258 + #E9E3FF + #000000 + #CCC4FF + #150B44 + #E9E3FD + #000000 + #CDC8E1 + #161426 + #FFDEEA + #000000 + #F1BCD2 + #2A0B1C + #141318 + #3A383E + #0E0E13 + #1C1B20 + #201F25 + #2A292F + #35343A + #EDC06C + #412D00 + #5D4200 + #FFDEA6 + #B5D087 + #233600 + #384D12 + #D1ECA0 + #F1C470 + #201400 + #B28B3D + #000000 + #B9D48A + #0E1900 + #809956 + #000000 + #FFFAF7 + #000000 + #F1C470 + #000000 + #F5FFDC + #000000 + #B9D48A + #000000 + \ No newline at end of file diff --git a/mastodon/src/main/res/values/strings.xml b/mastodon/src/main/res/values/strings.xml index 54bd3c2c..70c88808 100644 --- a/mastodon/src/main/res/values/strings.xml +++ b/mastodon/src/main/res/values/strings.xml @@ -747,4 +747,9 @@ Notifications from %s Notifications from %s have been dismissed. %s will now appear in your notification list. + Dismiss + Just once + Monthly + Yearly + Currency \ No newline at end of file