diff --git a/mastodon/build.gradle b/mastodon/build.gradle index c50342df4..c68f9d958 100644 --- a/mastodon/build.gradle +++ b/mastodon/build.gradle @@ -8,11 +8,11 @@ android { generateLocaleConfig = true } - compileSdk 33 + compileSdk 34 defaultConfig { applicationId "org.joinmastodon.android" minSdk 23 - targetSdk 33 + targetSdk 34 versionCode 108 versionName "2.5.6" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" @@ -101,7 +101,7 @@ dependencies { annotationProcessor 'org.parceler:parceler:1.1.12' coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.3' - def appCenterSdkVersion = "4.4.2" + def appCenterSdkVersion = "5.0.4" appcenterPrivateBetaImplementation "com.microsoft.appcenter:appcenter-crashes:${appCenterSdkVersion}" appcenterPrivateBetaImplementation "com.microsoft.appcenter:appcenter-distribute:${appCenterSdkVersion}" appcenterPublicBetaImplementation "com.microsoft.appcenter:appcenter-crashes:${appCenterSdkVersion}" diff --git a/mastodon/src/main/AndroidManifest.xml b/mastodon/src/main/AndroidManifest.xml index 7a97cfcee..f8cefa88c 100644 --- a/mastodon/src/main/AndroidManifest.xml +++ b/mastodon/src/main/AndroidManifest.xml @@ -4,6 +4,7 @@ + @@ -79,6 +80,7 @@ + diff --git a/mastodon/src/main/java/org/joinmastodon/android/AudioPlayerService.java b/mastodon/src/main/java/org/joinmastodon/android/AudioPlayerService.java index 4a01c13f0..de8bc1894 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/AudioPlayerService.java +++ b/mastodon/src/main/java/org/joinmastodon/android/AudioPlayerService.java @@ -88,8 +88,13 @@ public class AudioPlayerService extends Service{ nm=getSystemService(NotificationManager.class); // registerReceiver(receiver, new IntentFilter(Intent.ACTION_MEDIA_BUTTON)); registerReceiver(receiver, new IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY)); - registerReceiver(receiver, new IntentFilter(ACTION_PLAY_PAUSE)); - registerReceiver(receiver, new IntentFilter(ACTION_STOP)); + if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.TIRAMISU){ + registerReceiver(receiver, new IntentFilter(ACTION_PLAY_PAUSE), RECEIVER_EXPORTED); + registerReceiver(receiver, new IntentFilter(ACTION_STOP), RECEIVER_EXPORTED); + }else{ + registerReceiver(receiver, new IntentFilter(ACTION_PLAY_PAUSE)); + registerReceiver(receiver, new IntentFilter(ACTION_STOP)); + } instance=this; } diff --git a/mastodon/src/main/java/org/joinmastodon/android/DonationFragmentActivity.java b/mastodon/src/main/java/org/joinmastodon/android/DonationFragmentActivity.java new file mode 100644 index 000000000..5a72cd463 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/DonationFragmentActivity.java @@ -0,0 +1,29 @@ +package org.joinmastodon.android; + +import android.os.Bundle; + +import org.joinmastodon.android.fragments.DonationWebViewFragment; + +import androidx.annotation.Nullable; +import me.grishka.appkit.FragmentStackActivity; + +// This exists because our designer wanted to avoid extra sheet showing/hiding animations. +// This is the only way to show a fragment on top of a sheet without having to rewrite way too many things. +public class DonationFragmentActivity extends FragmentStackActivity{ + @Override + protected void onCreate(@Nullable Bundle savedInstanceState){ + super.onCreate(savedInstanceState); + if(savedInstanceState==null){ + DonationWebViewFragment fragment=new DonationWebViewFragment(); + fragment.setArguments(getIntent().getBundleExtra("fragmentArgs")); + showFragment(fragment); + overridePendingTransition(R.anim.fragment_enter, R.anim.no_op_300ms); + } + } + + @Override + public void finish(){ + super.finish(); + overridePendingTransition(0, R.anim.fragment_exit); + } +} 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 6e226bb65..3a027e7e2 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 000000000..bf8b711c9 --- /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/MastodonAPIController.java b/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIController.java index 9ea3bb769..128588304 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIController.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIController.java @@ -12,10 +12,12 @@ import com.google.gson.JsonParser; import com.google.gson.JsonSyntaxException; import org.joinmastodon.android.BuildConfig; +import org.joinmastodon.android.MastodonApp; import org.joinmastodon.android.api.gson.IsoInstantTypeAdapter; import org.joinmastodon.android.api.gson.IsoLocalDateTypeAdapter; import org.joinmastodon.android.api.session.AccountSession; +import java.io.File; import java.io.IOException; import java.io.Reader; import java.time.Instant; @@ -29,6 +31,8 @@ import java.util.concurrent.TimeUnit; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import me.grishka.appkit.utils.WorkerThread; +import okhttp3.Cache; +import okhttp3.CacheControl; import okhttp3.Call; import okhttp3.Callback; import okhttp3.OkHttpClient; @@ -49,8 +53,11 @@ public class MastodonAPIController{ .connectTimeout(60, TimeUnit.SECONDS) .writeTimeout(60, TimeUnit.SECONDS) .readTimeout(60, TimeUnit.SECONDS) + .cache(new Cache(new File(MastodonApp.context.getCacheDir(), "http"), 10*1024*1024)) .build(); + private static final CacheControl NO_CACHE_WHATSOEVER=new CacheControl.Builder().noCache().noStore().build(); + private AccountSession session; static{ @@ -80,6 +87,9 @@ public class MastodonAPIController{ if(token!=null) builder.header("Authorization", "Bearer "+token); + if(!req.cacheable) + builder.cacheControl(NO_CACHE_WHATSOEVER); + if(req.headers!=null){ for(Map.Entry header:req.headers.entrySet()){ builder.header(header.getKey(), header.getValue()); diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIRequest.java b/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIRequest.java index f308d8720..c780bbcf1 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIRequest.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIRequest.java @@ -46,6 +46,7 @@ public abstract class MastodonAPIRequest extends APIRequest{ boolean canceled; Map headers; long timeout; + boolean cacheable; private ProgressDialog progressDialog; protected boolean removeUnsupportedItems; @@ -132,6 +133,10 @@ public abstract class MastodonAPIRequest extends APIRequest{ this.timeout=timeout; } + protected void setCacheable(){ + cacheable=true; + } + protected String getPathPrefix(){ return "/api/v1"; } 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 000000000..3ff647fd8 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/catalog/GetDonationCampaigns.java @@ -0,0 +1,40 @@ +package org.joinmastodon.android.api.requests.catalog; + +import android.net.Uri; +import android.text.TextUtils; + +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.model.donations.DonationCampaign; + +public class GetDonationCampaigns extends MastodonAPIRequest{ + private final String locale, seed, source; + private boolean staging; + + public GetDonationCampaigns(String locale, String seed, String source){ + super(HttpMethod.GET, null, DonationCampaign.class); + this.locale=locale; + this.seed=seed; + this.source=source; + setCacheable(); + } + + public void setStaging(boolean staging){ + this.staging=staging; + } + + @Override + public Uri getURL(){ + Uri.Builder builder=new Uri.Builder() + .scheme("https") + .authority("api.joinmastodon.org") + .path("/v1/donations/campaigns/active") + .appendQueryParameter("platform", "android") + .appendQueryParameter("locale", locale) + .appendQueryParameter("seed", seed); + if(staging) + builder.appendQueryParameter("environment", "staging"); + if(!TextUtils.isEmpty(source)) + builder.appendQueryParameter("source", source); + 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 43190c73d..1276bf81e 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,8 @@ import org.joinmastodon.android.model.TimelineMarkers; import org.joinmastodon.android.model.Token; import org.joinmastodon.android.utils.ObjectIdComparator; -import java.io.File; +import java.time.Instant; +import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.List; import java.util.function.Consumer; @@ -44,6 +45,7 @@ import me.grishka.appkit.api.ErrorResponse; public class AccountSession{ private static final String TAG="AccountSession"; + private static final int MIN_DAYS_ACCOUNT_AGE_FOR_DONATIONS=28; public Token token; public Account self; @@ -276,4 +278,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)) && self.createdAt.isBefore(Instant.now().minus(MIN_DAYS_ACCOUNT_AGE_FOR_DONATIONS, ChronoUnit.DAYS)); + } + + 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 0d4211319..b55e07b61 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; @@ -450,6 +461,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; } @@ -459,4 +532,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 000000000..101369e17 --- /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 000000000..7f02bf73a --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/DonationWebViewFragment.java @@ -0,0 +1,96 @@ +package org.joinmastodon.android.fragments; + +import android.app.Activity; +import android.content.Intent; +import android.os.Bundle; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.webkit.WebResourceRequest; + +import org.joinmastodon.android.BuildConfig; +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/donate/success"; + public static final String FAILURE_URL="https://sponsor.joinmastodon.org/donate/failure"; + public static final String CANCEL_URL="https://sponsor.joinmastodon.org/donate/cancel"; + + @Override + public void onCreate(Bundle savedInstanceState){ + super.onCreate(savedInstanceState); + if(BuildConfig.DEBUG){ + setHasOptionsMenu(true); + } + } + + @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)){ + onSuccess(); + return true; + }else if(url.equalsIgnoreCase(FAILURE_URL)){ + onFailure(); + return true; + }else if(url.equalsIgnoreCase(CANCEL_URL)){ + onCancel(); + return true; + } + return false; + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){ + super.onCreateOptionsMenu(menu, inflater); + if(BuildConfig.DEBUG){ + menu.add(0, 0, 0, "Simulate success"); + menu.add(0, 1, 0, "Simulate failure"); + } + } + + @Override + public boolean onOptionsItemSelected(MenuItem item){ + if(item.getItemId()==0) + onSuccess(); + else if(item.getItemId()==1) + onFailure(); + return super.onOptionsItemSelected(item); + } + + private void onFailure(){ + new M3AlertDialogBuilder(getActivity()) + .setTitle(R.string.error) + .setMessage(R.string.donation_server_error) + .setPositiveButton(R.string.ok, null) + .setOnDismissListener(dlg->Nav.finish(this)) + .show(); + } + + private void onSuccess(){ + String campaignID=getArguments().getString("campaignID"); + AccountSessionManager.getInstance().markDonationCampaignAsDismissed(campaignID); + E.post(new DismissDonationCampaignBannerEvent(campaignID)); + getActivity().setResult(Activity.RESULT_OK, new Intent().putExtra("postText", getArguments().getString("successPostText"))); + getActivity().finish(); + } + + private void onCancel(){ + getActivity().finish(); + } +} 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 174aa2335..0a4205b8c 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTimelineFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTimelineFragment.java @@ -5,15 +5,23 @@ import android.animation.AnimatorListenerAdapter; import android.animation.AnimatorSet; import android.animation.ObjectAnimator; import android.app.Activity; +import android.content.Context; +import android.content.Intent; 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; @@ -26,14 +34,17 @@ import android.widget.Toolbar; import com.squareup.otto.Subscribe; +import org.joinmastodon.android.BuildConfig; 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 +52,11 @@ 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.sheets.DonationSuccessfulSheet; import org.joinmastodon.android.ui.utils.DiscoverInfoBannerHelper; import org.joinmastodon.android.ui.viewcontrollers.HomeTimelineMenuController; import org.joinmastodon.android.ui.viewcontrollers.ToolbarDropdownMenuController; @@ -53,6 +67,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; @@ -64,8 +79,11 @@ import me.grishka.appkit.api.SimpleCallback; import me.grishka.appkit.utils.CubicBezierInterpolator; import me.grishka.appkit.utils.MergeRecyclerAdapter; import me.grishka.appkit.utils.V; +import me.grishka.appkit.views.BottomSheet; public class HomeTimelineFragment extends StatusListFragment implements ToolbarDropdownMenuController.HostFragment{ + private static final int DONATION_RESULT=211; + private ImageButton fab; private LinearLayout listsDropdown; private FixedAspectRatioImageView listsDropdownArrow; @@ -81,9 +99,13 @@ 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; + private BottomSheet donationSheet; public HomeTimelineFragment(){ setListLayoutId(R.layout.fragment_timeline); @@ -93,6 +115,32 @@ public class HomeTimelineFragment extends StatusListFragment implements ToolbarD public void onCreate(Bundle savedInstanceState){ super.onCreate(savedInstanceState); localTimelineBannerHelper=new DiscoverInfoBannerHelper(DiscoverInfoBannerHelper.BannerType.LOCAL_TIMELINE, accountID); + + if(AccountSessionManager.get(accountID).isEligibleForDonations()){ + GetDonationCampaigns req=new GetDonationCampaigns(Locale.getDefault().toLanguageTag().replace('-', '_'), String.valueOf(AccountSessionManager.get(accountID).getDonationSeed()), null); + if(getActivity().getSharedPreferences("debug", Context.MODE_PRIVATE).getBoolean("donationsStaging", false)){ + req.setStaging(true); + } + req.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(""); + } + E.register(this); + } + + @Override + public void onDestroy(){ + super.onDestroy(); + E.unregister(this); } @Override @@ -233,6 +281,8 @@ public class HomeTimelineFragment extends StatusListFragment implements ToolbarD E.register(this); updateUpdateState(GithubSelfUpdater.getInstance().getState()); } + if(currentDonationCampaign!=null) + showDonationBanner(currentDonationCampaign); } @Override @@ -587,6 +637,8 @@ public class HomeTimelineFragment extends StatusListFragment implements ToolbarD if(GithubSelfUpdater.needSelfUpdating()){ E.unregister(this); } + donationBanner=null; + donationBannerDismissing=false; } private void updateUpdateState(GithubSelfUpdater.UpdateState state){ @@ -599,6 +651,13 @@ public class HomeTimelineFragment extends StatusListFragment implements ToolbarD updateUpdateState(ev.state); } + @Subscribe + public void onDismissDonationCampaignBanner(DismissDonationCampaignBannerEvent ev){ + if(currentDonationCampaign!=null && ev.campaignID.equals(currentDonationCampaign.id)){ + dismissDonationBanner(); + } + } + @Override protected boolean shouldRemoveAccountPostsWhenUnfollowing(){ return true; @@ -653,6 +712,17 @@ public class HomeTimelineFragment extends StatusListFragment implements ToolbarD super.onDataLoaded(d, more); } + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data){ + if(requestCode==DONATION_RESULT){ + if(donationSheet!=null) + donationSheet.dismissWithoutAnimation(); + if(resultCode==Activity.RESULT_OK){ + new DonationSuccessfulSheet(getActivity(), accountID, data.getStringExtra("postText")).showWithoutAnimation(); + } + } + } + private String getCurrentListTitle(){ return switch(listMode){ case FOLLOWING -> getString(R.string.timeline_following); @@ -661,6 +731,77 @@ 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(){ + donationSheet=new DonationSheet(getActivity(), currentDonationCampaign, accountID, intent->startActivityForResult(intent, DONATION_RESULT)); + donationSheet.setOnDismissListener(dialog->donationSheet=null); + donationSheet.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 000000000..7cd3022a7 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/WebViewFragment.java @@ -0,0 +1,100 @@ +package org.joinmastodon.android.fragments; + +import android.os.Bundle; +import android.util.Log; +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.BuildConfig; +import org.joinmastodon.android.api.MastodonErrorResponse; + +import me.grishka.appkit.Nav; +import me.grishka.appkit.fragments.LoaderFragment; + +public abstract class WebViewFragment extends LoaderFragment{ + private static final String TAG="WebViewFragment"; + + protected WebView webView; + private Runnable backCallback=this::onGoBack; + private boolean backCallbackSet; + + @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){ + if(BuildConfig.DEBUG){ + Log.d(TAG, "onPageFinished: "+url); + } + dataLoaded(); + updateBackCallback(); + } + + @Override + public void onReceivedError(WebView view, WebResourceRequest request, WebResourceError error){ + onError(new MastodonErrorResponse(error.getDescription().toString(), -1, null)); + updateBackCallback(); + } + + @Override + public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request){ + return WebViewFragment.this.shouldOverrideUrlLoading(request); + } + + @Override + public void doUpdateVisitedHistory(WebView view, String url, boolean isReload){ + updateBackCallback(); + } + }); + webView.getSettings().setJavaScriptEnabled(true); + return webView; + } + + @Override + protected void doLoadData(){ + + } + + @Override + public void onRefresh(){ + webView.reload(); + } + + @Override + public void onToolbarNavigationClick(){ + Nav.finish(this); + } + + private void updateBackCallback(){ + boolean canGoBack=webView.canGoBack(); + if(canGoBack!=backCallbackSet){ + if(canGoBack){ + addBackCallback(backCallback); + backCallbackSet=true; + }else{ + removeBackCallback(backCallback); + backCallbackSet=false; + } + } + } + + private void onGoBack(){ + if(webView.canGoBack()) + webView.goBack(); + } + + 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 755238e83..ce6e0add9 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 @@ -1,5 +1,7 @@ package org.joinmastodon.android.fragments.settings; +import android.content.Context; +import android.content.SharedPreferences; import android.os.Bundle; import android.widget.Toast; @@ -9,6 +11,7 @@ import org.joinmastodon.android.api.session.AccountSession; import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.fragments.HomeFragment; import org.joinmastodon.android.fragments.onboarding.AccountActivationFragment; +import org.joinmastodon.android.model.viewmodel.CheckableListItem; import org.joinmastodon.android.model.viewmodel.ListItem; import org.joinmastodon.android.ui.utils.DiscoverInfoBannerHelper; import org.joinmastodon.android.updater.GithubSelfUpdater; @@ -18,6 +21,8 @@ import java.util.List; import me.grishka.appkit.Nav; public class SettingsDebugFragment extends BaseSettingsFragment{ + private CheckableListItem donationsStagingItem; + @Override public void onCreate(Bundle savedInstanceState){ super.onCreate(savedInstanceState); @@ -28,7 +33,9 @@ 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), + donationsStagingItem=new CheckableListItem<>("Use staging environment for donations", null, CheckableListItem.Style.SWITCH, getPrefs().getBoolean("donationsStaging", false), this::toggleCheckableItem) )); if(!GithubSelfUpdater.needSelfUpdating()){ resetUpdateItem.isEnabled=selfUpdateItem.isEnabled=false; @@ -39,6 +46,12 @@ public class SettingsDebugFragment extends BaseSettingsFragment{ @Override protected void doLoadData(int offset, int count){} + @Override + public void onStop(){ + super.onStop(); + getPrefs().edit().putBoolean("donationsStaging", donationsStagingItem.checked).apply(); + } + private void onTestEmailConfirmClick(ListItem item){ AccountSession sess=AccountSessionManager.getInstance().getAccount(accountID); sess.activated=false; @@ -70,9 +83,18 @@ 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); Nav.goClearingStack(getActivity(), HomeFragment.class, args); } + + private SharedPreferences getPrefs(){ + return getActivity().getSharedPreferences("debug", Context.MODE_PRIVATE); + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsMainFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsMainFragment.java index bb5a806d7..78f3fede3 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsMainFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsMainFragment.java @@ -1,10 +1,14 @@ package org.joinmastodon.android.fragments.settings; +import android.app.Activity; +import android.content.Context; +import android.content.Intent; import android.os.Bundle; import android.view.View; import android.widget.Button; import android.widget.ImageView; import android.widget.TextView; +import android.widget.Toast; import com.squareup.otto.Subscribe; @@ -12,27 +16,38 @@ import org.joinmastodon.android.BuildConfig; import org.joinmastodon.android.E; import org.joinmastodon.android.MainActivity; import org.joinmastodon.android.R; +import org.joinmastodon.android.api.requests.catalog.GetDonationCampaigns; import org.joinmastodon.android.api.session.AccountSession; import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.events.SelfUpdateStateChangedEvent; +import org.joinmastodon.android.model.donations.DonationCampaign; import org.joinmastodon.android.model.viewmodel.ListItem; -import org.joinmastodon.android.ui.sheets.AccountSwitcherSheet; import org.joinmastodon.android.ui.M3AlertDialogBuilder; +import org.joinmastodon.android.ui.sheets.AccountSwitcherSheet; +import org.joinmastodon.android.ui.sheets.DonationSheet; +import org.joinmastodon.android.ui.sheets.DonationSuccessfulSheet; import org.joinmastodon.android.ui.utils.HideableSingleViewRecyclerAdapter; import org.joinmastodon.android.ui.utils.UiUtils; import org.joinmastodon.android.updater.GithubSelfUpdater; +import java.util.ArrayList; import java.util.List; +import java.util.Locale; import androidx.recyclerview.widget.RecyclerView; import me.grishka.appkit.Nav; +import me.grishka.appkit.api.Callback; +import me.grishka.appkit.api.ErrorResponse; import me.grishka.appkit.utils.MergeRecyclerAdapter; public class SettingsMainFragment extends BaseSettingsFragment{ + private static final int DONATION_RESULT=433; + private boolean loggedOut; private HideableSingleViewRecyclerAdapter bannerAdapter; private Button updateButton1, updateButton2; private TextView updateText; + private DonationSheet donationSheet; private Runnable updateDownloadProgressUpdater=new Runnable(){ @Override public void run(){ @@ -49,21 +64,26 @@ public class SettingsMainFragment extends BaseSettingsFragment{ super.onCreate(savedInstanceState); setTitle(R.string.settings); setSubtitle(AccountSessionManager.get(accountID).getFullUsername()); - onDataLoaded(List.of( + ArrayList> items=new ArrayList<>(); + if(BuildConfig.DEBUG || BuildConfig.BUILD_TYPE.equals("appcenterPrivateBeta")){ + items.add(new ListItem<>("Debug settings", null, R.drawable.ic_settings_24px, i->Nav.go(getActivity(), SettingsDebugFragment.class, makeFragmentArgs()), null, 0, true)); + } + items.addAll(List.of( new ListItem<>(R.string.settings_behavior, 0, R.drawable.ic_settings_24px, this::onBehaviorClick), new ListItem<>(R.string.settings_display, 0, R.drawable.ic_style_24px, this::onDisplayClick), new ListItem<>(R.string.settings_privacy, 0, R.drawable.ic_privacy_tip_24px, this::onPrivacyClick), new ListItem<>(R.string.settings_filters, 0, R.drawable.ic_filter_alt_24px, this::onFiltersClick), new ListItem<>(R.string.settings_notifications, 0, R.drawable.ic_notifications_24px, this::onNotificationsClick), new ListItem<>(AccountSessionManager.get(accountID).domain, getString(R.string.settings_server_explanation), R.drawable.ic_dns_24px, this::onServerClick), - new ListItem<>(getString(R.string.about_app, getString(R.string.app_name)), null, R.drawable.ic_info_24px, this::onAboutClick, null, 0, true), - new ListItem<>(R.string.manage_accounts, 0, R.drawable.ic_switch_account_24px, this::onManageAccountsClick), - new ListItem<>(R.string.log_out, 0, R.drawable.ic_logout_24px, this::onLogOutClick, R.attr.colorM3Error, false) + new ListItem<>(getString(R.string.about_app, getString(R.string.app_name)), null, R.drawable.ic_info_24px, this::onAboutClick, null, 0, true) )); - - if(BuildConfig.DEBUG || BuildConfig.BUILD_TYPE.equals("appcenterPrivateBeta")){ - data.add(0, new ListItem<>("Debug settings", null, R.drawable.ic_settings_24px, i->Nav.go(getActivity(), SettingsDebugFragment.class, makeFragmentArgs()), null, 0, true)); + if(AccountSessionManager.get(accountID).isEligibleForDonations()){ + items.add(new ListItem<>(R.string.settings_donate, 0, R.drawable.ic_volunteer_activism_24px, this::onDonateClick)); + items.add(new ListItem<>(R.string.settings_manage_donations, 0, R.drawable.ic_settings_heart_24px, this::onManageDonationClick, 0, true)); } + items.add(new ListItem<>(R.string.manage_accounts, 0, R.drawable.ic_switch_account_24px, this::onManageAccountsClick)); + items.add(new ListItem<>(R.string.log_out, 0, R.drawable.ic_logout_24px, this::onLogOutClick, R.attr.colorM3Error, false)); + onDataLoaded(items); AccountSession session=AccountSessionManager.get(accountID); session.reloadPreferences(null); @@ -117,6 +137,17 @@ public class SettingsMainFragment extends BaseSettingsFragment{ } } + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data){ + if(requestCode==DONATION_RESULT){ + if(donationSheet!=null) + donationSheet.dismissWithoutAnimation(); + if(resultCode==Activity.RESULT_OK){ + new DonationSuccessfulSheet(getActivity(), accountID, data.getStringExtra("postText")).showWithoutAnimation(); + } + } + } + private Bundle makeFragmentArgs(){ Bundle args=new Bundle(); args.putString("account", accountID); @@ -167,6 +198,39 @@ public class SettingsMainFragment extends BaseSettingsFragment{ .show(); } + private void onDonateClick(ListItem item){ + GetDonationCampaigns req=new GetDonationCampaigns(Locale.getDefault().toLanguageTag().replace('-', '_'), String.valueOf(AccountSessionManager.get(accountID).getDonationSeed()), null); + if(getActivity().getSharedPreferences("debug", Context.MODE_PRIVATE).getBoolean("donationsStaging", false)){ + req.setStaging(true); + } + req.setCallback(new Callback<>(){ + @Override + public void onSuccess(DonationCampaign result){ + Activity activity=getActivity(); + if(activity==null) + return; + if(result==null){ + Toast.makeText(activity, "No campaign available (server misconfiguration?)", Toast.LENGTH_SHORT).show(); + return; + } + donationSheet=new DonationSheet(getActivity(), result, accountID, intent->startActivityForResult(intent, DONATION_RESULT)); + donationSheet.setOnDismissListener(dialog->donationSheet=null); + donationSheet.show(); + } + + @Override + public void onError(ErrorResponse error){ + error.showToast(getActivity()); + } + }) + .wrapProgress(getActivity(), R.string.loading, true) + .execNoAuth(""); + } + + private void onManageDonationClick(ListItem item){ + UiUtils.launchWebBrowser(getActivity(), "https://sponsor.staging.joinmastodon.org/donate/manage"); + } + @Subscribe public void onSelfUpdateStateChanged(SelfUpdateStateChangedEvent ev){ updateUpdateBanner(); 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 000000000..fe672bc0a --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/model/donations/DonationCampaign.java @@ -0,0 +1,34 @@ +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; + public String donationSuccessPost; + + @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 000000000..a6e0ef89f --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/sheets/DonationSheet.java @@ -0,0 +1,353 @@ +package org.joinmastodon.android.ui.sheets; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.graphics.drawable.ColorDrawable; +import android.net.Uri; +import android.os.Bundle; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.Window; +import android.view.WindowManager; +import android.widget.TextView; +import android.widget.Toast; +import android.widget.ToggleButton; + +import org.joinmastodon.android.DonationFragmentActivity; +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.Arrays; +import java.util.Currency; +import java.util.List; +import java.util.Locale; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +import androidx.annotation.NonNull; +import me.grishka.appkit.utils.CustomViewHelper; +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 final Consumer startCallback; + 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, Consumer startCallback){ + super(activity); + this.campaign=campaign; + this.accountID=accountID; + this.activity=activity; + this.startCallback=startCallback; + 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); + + ViewGroup suggestedAmounts=findViewById(R.id.suggested_amounts); + for(int i=0;iopenWebView()); + + Arrays.stream(getCurrentSuggestedAmounts(campaign.defaultCurrency)).min().ifPresent(amountField::setAmount); + } + + @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("cancel_callback_url", DonationWebViewFragment.CANCEL_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); + args.putString("successPostText", campaign.donationSuccessPost); + args.putBoolean("_can_go_back", true); + startCallback.accept(new Intent(activity, DonationFragmentActivity.class).putExtra("fragmentArgs", args)); + } + + 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 + } + + public static class SuggestedAmountsLayout extends ViewGroup implements CustomViewHelper{ + private int visibleChildCount; + private static final int H_GAP=24; + private static final int V_GAP=8; + private static final int ROW_HEIGHT=32; + + public SuggestedAmountsLayout(Context context){ + this(context, null); + } + + public SuggestedAmountsLayout(Context context, AttributeSet attrs){ + this(context, attrs, 0); + } + + public SuggestedAmountsLayout(Context context, AttributeSet attrs, int defStyle){ + super(context, attrs, defStyle); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec){ + visibleChildCount=0; + for(int i=0;i4 ? dp(ROW_HEIGHT*2+V_GAP) : dp(ROW_HEIGHT)); + int buttonsPerRow=visibleChildCount>4 ? 3 : visibleChildCount; + int buttonWidth=(width-dp(H_GAP)*(buttonsPerRow-1))/buttonsPerRow; + for(int i=0;i4 ? 3 : visibleChildCount; + int buttonWidth=(width-dp(H_GAP)*(buttonsPerRow-1))/buttonsPerRow; + for(int i=0;idismiss()); + View shareButton=content.findViewById(R.id.btn_share); + if(postText==null){ + shareButton.setEnabled(false); + } + shareButton.setOnClickListener(v->{ + Bundle args=new Bundle(); + args.putString("account", accountID); + args.putString("prefilledText", postText); + Nav.go((Activity) context, ComposeFragment.class, args); + dismiss(); + }); + } +} 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 000000000..bae0f93c8 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/views/CurrencyAmountInput.java @@ -0,0 +1,340 @@ +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()); + currencyBtn.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_unfold_more_wght600_15pt_8x20px, 0, 0, 0); + currencyBtn.setCompoundDrawableTintList(currencyBtn.getTextColors()); + currencyBtn.setCompoundDrawablePadding(dp(4)); + 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); + } + + @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){ + this.paint.setColor(paint.getColor()); + this.paint.setAlpha(77); + 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/anim/fragment_enter.xml b/mastodon/src/main/res/anim/fragment_enter.xml new file mode 100644 index 000000000..073d62d12 --- /dev/null +++ b/mastodon/src/main/res/anim/fragment_enter.xml @@ -0,0 +1,13 @@ + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/anim/fragment_exit.xml b/mastodon/src/main/res/anim/fragment_exit.xml new file mode 100644 index 000000000..8dcf659c9 --- /dev/null +++ b/mastodon/src/main/res/anim/fragment_exit.xml @@ -0,0 +1,13 @@ + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/anim/no_op_300ms.xml b/mastodon/src/main/res/anim/no_op_300ms.xml new file mode 100644 index 000000000..86216b32a --- /dev/null +++ b/mastodon/src/main/res/anim/no_op_300ms.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable-nodpi/donation_successful_art.webp b/mastodon/src/main/res/drawable-nodpi/donation_successful_art.webp new file mode 100644 index 000000000..133a13f4e Binary files /dev/null and b/mastodon/src/main/res/drawable-nodpi/donation_successful_art.webp differ 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 000000000..abfe4d274 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 000000000..de2ddedd8 --- /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 d11366254..3072547e7 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 000000000..13f79a328 --- /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_campaign_20px.xml b/mastodon/src/main/res/drawable/ic_campaign_20px.xml new file mode 100644 index 000000000..b386285c5 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_campaign_20px.xml @@ -0,0 +1,9 @@ + + + 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 000000000..2e796f13f --- /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 000000000..c4066a427 --- /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_settings_heart_24px.xml b/mastodon/src/main/res/drawable/ic_settings_heart_24px.xml new file mode 100644 index 000000000..4d6527773 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_settings_heart_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_unfold_more_wght600_15pt_8x20px.xml b/mastodon/src/main/res/drawable/ic_unfold_more_wght600_15pt_8x20px.xml new file mode 100644 index 000000000..2e09a03dd --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_unfold_more_wght600_15pt_8x20px.xml @@ -0,0 +1,11 @@ + + + + + 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 000000000..5f252b533 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_volunteer_activism_20px.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_volunteer_activism_24px.xml b/mastodon/src/main/res/drawable/ic_volunteer_activism_24px.xml new file mode 100644 index 000000000..5a95829b8 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_volunteer_activism_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/interpolator-v21/cubic_bezier_default.xml b/mastodon/src/main/res/interpolator-v21/cubic_bezier_default.xml new file mode 100644 index 000000000..9d568940b --- /dev/null +++ b/mastodon/src/main/res/interpolator-v21/cubic_bezier_default.xml @@ -0,0 +1,3 @@ + + 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 000000000..c25e4be12 --- /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 defa53bcf..e39c20575 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 000000000..7b4fe156b --- /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/layout/sheet_donation_success.xml b/mastodon/src/main/res/layout/sheet_donation_success.xml new file mode 100644 index 000000000..323fb6798 --- /dev/null +++ b/mastodon/src/main/res/layout/sheet_donation_success.xml @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + +