added a basic compose screen, and the 3 main timelines in a tabbed layout

This commit is contained in:
Vavassor 2017-01-07 17:24:02 -05:00
parent b78ccb1b49
commit 370b1e52aa
30 changed files with 1367 additions and 234 deletions

View file

@ -10,6 +10,7 @@ android {
versionCode 1 versionCode 1
versionName "1.0" versionName "1.0"
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary true
} }
buildTypes { buildTypes {
release { release {
@ -28,4 +29,5 @@ dependencies {
compile 'com.android.support:recyclerview-v7:25.1.0' compile 'com.android.support:recyclerview-v7:25.1.0'
compile 'com.android.volley:volley:1.0.0' compile 'com.android.volley:volley:1.0.0'
testCompile 'junit:junit:4.12' testCompile 'junit:junit:4.12'
compile 'com.android.support:design:25.1.0'
} }

View file

@ -27,6 +27,9 @@
</intent-filter> </intent-filter>
</activity> </activity>
<activity android:name=".MainActivity" /> <activity android:name=".MainActivity" />
<activity
android:name=".ComposeActivity"
android:windowSoftInputMode="stateVisible|adjustResize" />
</application> </application>
</manifest> </manifest>

View file

@ -0,0 +1,137 @@
package com.keylesspalace.tusky;
import android.content.Context;
import android.content.SharedPreferences;
import android.os.Bundle;
import android.support.v7.app.AppCompatActivity;
import android.text.Editable;
import android.text.TextWatcher;
import android.view.View;
import android.widget.Button;
import android.widget.EditText;
import android.widget.RadioGroup;
import android.widget.TextView;
import android.widget.Toast;
import com.android.volley.AuthFailureError;
import com.android.volley.Request;
import com.android.volley.Response;
import com.android.volley.VolleyError;
import com.android.volley.toolbox.JsonObjectRequest;
import org.json.JSONException;
import org.json.JSONObject;
import java.util.HashMap;
import java.util.Map;
public class ComposeActivity extends AppCompatActivity {
private static int STATUS_CHARACTER_LIMIT = 500;
private String domain;
private String accessToken;
private EditText textEditor;
private void onSendSuccess() {
Toast.makeText(this, "Toot!", Toast.LENGTH_SHORT).show();
finish();
}
private void onSendFailure(Exception exception) {
textEditor.setError(getString(R.string.error_sending_status));
}
private void sendStatus(String content, String visibility) {
String endpoint = getString(R.string.endpoint_status);
String url = "https://" + domain + endpoint;
JSONObject parameters = new JSONObject();
try {
parameters.put("status", content);
parameters.put("visibility", visibility);
} catch (JSONException e) {
onSendFailure(e);
return;
}
JsonObjectRequest request = new JsonObjectRequest(Request.Method.POST, url, parameters,
new Response.Listener<JSONObject>() {
@Override
public void onResponse(JSONObject response) {
onSendSuccess();
}
}, new Response.ErrorListener() {
@Override
public void onErrorResponse(VolleyError error) {
onSendFailure(error);
}
}) {
@Override
public Map<String, String> getHeaders() throws AuthFailureError {
Map<String, String> headers = new HashMap<>();
headers.put("Authorization", "Bearer " + accessToken);
return headers;
}
};
VolleySingleton.getInstance(this).addToRequestQueue(request);
}
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_compose);
SharedPreferences preferences = getSharedPreferences(
getString(R.string.preferences_file_key), Context.MODE_PRIVATE);
domain = preferences.getString("domain", null);
accessToken = preferences.getString("accessToken", null);
assert(domain != null);
assert(accessToken != null);
textEditor = (EditText) findViewById(R.id.field_status);
final TextView charactersLeft = (TextView) findViewById(R.id.characters_left);
TextWatcher textEditorWatcher = new TextWatcher() {
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
int left = STATUS_CHARACTER_LIMIT - s.length();
charactersLeft.setText(Integer.toString(left));
}
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
@Override
public void afterTextChanged(Editable s) {}
};
textEditor.addTextChangedListener(textEditorWatcher);
final RadioGroup radio = (RadioGroup) findViewById(R.id.radio_visibility);
final Button sendButton = (Button) findViewById(R.id.button_send);
sendButton.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
Editable editable = textEditor.getText();
if (editable.length() <= STATUS_CHARACTER_LIMIT) {
int id = radio.getCheckedRadioButtonId();
String visibility;
switch (id) {
default:
case R.id.radio_public: {
visibility = "public";
break;
}
case R.id.radio_unlisted: {
visibility = "unlisted";
break;
}
case R.id.radio_private: {
visibility = "private";
break;
}
}
sendStatus(editable.toString(), visibility);
} else {
textEditor.setError(getString(R.string.error_compose_character_limit));
}
}
});
}
}

View file

@ -26,6 +26,8 @@ import java.util.HashMap;
import java.util.Map; import java.util.Map;
public class LoginActivity extends AppCompatActivity { public class LoginActivity extends AppCompatActivity {
private static String OAUTH_SCOPES = "read write follow";
private SharedPreferences preferences; private SharedPreferences preferences;
private String domain; private String domain;
private String clientId; private String clientId;
@ -71,6 +73,7 @@ public class LoginActivity extends AppCompatActivity {
parameters.put("client_id", clientId); parameters.put("client_id", clientId);
parameters.put("redirect_uri", redirectUri); parameters.put("redirect_uri", redirectUri);
parameters.put("response_type", "code"); parameters.put("response_type", "code");
parameters.put("scope", OAUTH_SCOPES);
String queryParameters; String queryParameters;
try { try {
queryParameters = toQueryString(parameters); queryParameters = toQueryString(parameters);
@ -107,7 +110,7 @@ public class LoginActivity extends AppCompatActivity {
try { try {
parameters.put("client_name", getString(R.string.app_name)); parameters.put("client_name", getString(R.string.app_name));
parameters.put("redirect_uris", getOauthRedirectUri()); parameters.put("redirect_uris", getOauthRedirectUri());
parameters.put("scopes", "read write follow"); parameters.put("scopes", OAUTH_SCOPES);
} catch (JSONException e) { } catch (JSONException e) {
//TODO: error text???? //TODO: error text????
return; return;

View file

@ -3,228 +3,41 @@ package com.keylesspalace.tusky;
import android.content.Context; import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.content.SharedPreferences; import android.content.SharedPreferences;
import android.graphics.drawable.Drawable; import android.support.design.widget.TabLayout;
import android.os.Build; import android.support.v4.view.ViewPager;
import android.support.v4.content.ContextCompat;
import android.support.v4.widget.SwipeRefreshLayout;
import android.support.v7.app.AppCompatActivity; import android.support.v7.app.AppCompatActivity;
import android.os.Bundle; import android.os.Bundle;
import android.support.v7.widget.DividerItemDecoration;
import android.support.v7.widget.LinearLayoutManager;
import android.support.v7.widget.RecyclerView;
import android.support.v7.widget.Toolbar; import android.support.v7.widget.Toolbar;
import android.text.Html;
import android.text.Spanned;
import android.view.Menu; import android.view.Menu;
import android.view.MenuItem; import android.view.MenuItem;
import android.widget.Toast;
import com.android.volley.AuthFailureError;
import com.android.volley.Response;
import com.android.volley.VolleyError;
import com.android.volley.toolbox.JsonArrayRequest;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class MainActivity extends AppCompatActivity implements
SwipeRefreshLayout.OnRefreshListener {
private String domain = null;
private String accessToken = null;
private SwipeRefreshLayout swipeRefreshLayout;
private RecyclerView recyclerView;
private TimelineAdapter adapter;
private LinearLayoutManager layoutManager;
private EndlessOnScrollListener scrollListener;
public class MainActivity extends AppCompatActivity {
@Override @Override
protected void onCreate(Bundle savedInstanceState) { protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main); setContentView(R.layout.activity_main);
Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar); Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
setSupportActionBar(toolbar); setSupportActionBar(toolbar);
SharedPreferences preferences = getSharedPreferences( TimelinePagerAdapter adapter = new TimelinePagerAdapter(getSupportFragmentManager());
getString(R.string.preferences_file_key), Context.MODE_PRIVATE); String[] pageTitles = {
domain = preferences.getString("domain", null); getString(R.string.title_home),
accessToken = preferences.getString("accessToken", null); getString(R.string.title_notifications),
assert(domain != null); getString(R.string.title_public)
assert(accessToken != null);
// Setup the SwipeRefreshLayout.
swipeRefreshLayout = (SwipeRefreshLayout) findViewById(R.id.swipe_refresh_layout);
swipeRefreshLayout.setOnRefreshListener(this);
// Setup the RecyclerView.
recyclerView = (RecyclerView) findViewById(R.id.recycler_view);
recyclerView.setHasFixedSize(true);
layoutManager = new LinearLayoutManager(this);
recyclerView.setLayoutManager(layoutManager);
DividerItemDecoration divider = new DividerItemDecoration(
this, layoutManager.getOrientation());
Drawable drawable = ContextCompat.getDrawable(this, R.drawable.status_divider);
divider.setDrawable(drawable);
recyclerView.addItemDecoration(divider);
scrollListener = new EndlessOnScrollListener(layoutManager) {
@Override
public void onLoadMore(int page, int totalItemsCount, RecyclerView view) {
TimelineAdapter adapter = (TimelineAdapter) view.getAdapter();
String fromId = adapter.getItem(adapter.getItemCount() - 1).getId();
sendFetchTimelineRequest(fromId);
}
}; };
recyclerView.addOnScrollListener(scrollListener); adapter.setPageTitles(pageTitles);
adapter = new TimelineAdapter(); ViewPager viewPager = (ViewPager) findViewById(R.id.pager);
recyclerView.setAdapter(adapter); viewPager.setAdapter(adapter);
TabLayout tabLayout = (TabLayout) findViewById(R.id.tab_layout);
sendFetchTimelineRequest(); tabLayout.setupWithViewPager(viewPager);
} }
private Date parseDate(String dateTime) { private void compose() {
Date date; Intent intent = new Intent(this, ComposeActivity.class);
String s = dateTime.replace("Z", "+00:00"); startActivity(intent);
SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSZ");
try {
date = format.parse(s);
} catch (ParseException e) {
e.printStackTrace();
return null;
}
return date;
} }
private CharSequence trimTrailingWhitespace(CharSequence s) {
int i = s.length();
do {
i--;
} while (i >= 0 && Character.isWhitespace(s.charAt(i)));
return s.subSequence(0, i + 1);
}
private Spanned compatFromHtml(String html) {
Spanned result;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
result = Html.fromHtml(html, Html.FROM_HTML_MODE_LEGACY);
} else {
result = Html.fromHtml(html);
}
/* Html.fromHtml returns trailing whitespace if the html ends in a </p> tag, which
* all status contents do, so it should be trimmed. */
return (Spanned) trimTrailingWhitespace(result);
}
private Status parseStatus(JSONObject object, boolean isReblog) throws JSONException {
String id = object.getString("id");
String content = object.getString("content");
Date createdAt = parseDate(object.getString("created_at"));
JSONObject account = object.getJSONObject("account");
String displayName = account.getString("display_name");
String username = account.getString("acct");
String avatar = account.getString("avatar");
Status reblog = null;
/* This case shouldn't be hit after the first recursion at all. But if this method is
* passed unusual data this check will prevent extra recursion */
if (!isReblog) {
JSONObject reblogObject = object.optJSONObject("reblog");
if (reblogObject != null) {
reblog = parseStatus(reblogObject, true);
}
}
Status status;
if (reblog != null) {
status = reblog;
status.setRebloggedByUsername(username);
} else {
Spanned contentPlus = compatFromHtml(content);
status = new Status(id, displayName, username, contentPlus, avatar, createdAt);
}
return status;
}
private List<Status> parseStatuses(JSONArray array) throws JSONException {
List<Status> statuses = new ArrayList<>();
for (int i = 0; i < array.length(); i++) {
JSONObject object = array.getJSONObject(i);
statuses.add(parseStatus(object, false));
}
return statuses;
}
private void sendFetchTimelineRequest(final String fromId) {
String endpoint = getString(R.string.endpoint_timelines_home);
String url = "https://" + domain + endpoint;
JsonArrayRequest request = new JsonArrayRequest(url,
new Response.Listener<JSONArray>() {
@Override
public void onResponse(JSONArray response) {
List<Status> statuses = null;
try {
statuses = parseStatuses(response);
} catch (JSONException e) {
onFetchTimelineFailure(e);
}
if (statuses != null) {
onFetchTimelineSuccess(statuses, fromId != null);
}
}
}, new Response.ErrorListener() {
@Override
public void onErrorResponse(VolleyError error) {
onFetchTimelineFailure(error);
}
}) {
@Override
public Map<String, String> getHeaders() throws AuthFailureError {
Map<String, String> headers = new HashMap<>();
headers.put("Authorization", "Bearer " + accessToken);
return headers;
}
@Override
protected Map<String, String> getParams() throws AuthFailureError {
Map<String, String> parameters = new HashMap<>();
parameters.put("max_id", fromId);
return parameters;
}
};
VolleySingleton.getInstance(this).addToRequestQueue(request);
}
private void sendFetchTimelineRequest() {
sendFetchTimelineRequest(null);
}
public void onFetchTimelineSuccess(List<Status> statuses, boolean added) {
if (added) {
adapter.addItems(statuses);
} else {
adapter.update(statuses);
}
swipeRefreshLayout.setRefreshing(false);
}
public void onFetchTimelineFailure(Exception exception) {
Toast.makeText(this, R.string.error_fetching_timeline, Toast.LENGTH_SHORT).show();
swipeRefreshLayout.setRefreshing(false);
}
public void onRefresh() {
sendFetchTimelineRequest();
}
private void logOut() { private void logOut() {
SharedPreferences preferences = getSharedPreferences( SharedPreferences preferences = getSharedPreferences(
getString(R.string.preferences_file_key), Context.MODE_PRIVATE); getString(R.string.preferences_file_key), Context.MODE_PRIVATE);
@ -246,13 +59,15 @@ public class MainActivity extends AppCompatActivity implements
@Override @Override
public boolean onOptionsItemSelected(MenuItem item) { public boolean onOptionsItemSelected(MenuItem item) {
switch (item.getItemId()) { switch (item.getItemId()) {
case R.id.action_compose: {
compose();
return true;
}
case R.id.action_logout: { case R.id.action_logout: {
logOut(); logOut();
return true; return true;
} }
default: {
return super.onOptionsItemSelected(item);
}
} }
return super.onOptionsItemSelected(item);
} }
} }

View file

@ -0,0 +1,43 @@
package com.keylesspalace.tusky;
import android.support.annotation.Nullable;
public class Notification {
public enum Type {
MENTION,
REBLOG,
FAVOURITE,
FOLLOW,
}
private Type type;
private String id;
private String displayName;
/** Which of the user's statuses has been mentioned, reblogged, or favourited. */
private Status status;
public Notification(Type type, String id, String displayName) {
this.type = type;
this.id = id;
this.displayName = displayName;
}
public Type getType() {
return type;
}
public String getId() {
return id;
}
public String getDisplayName() {
return displayName;
}
public @Nullable Status getStatus() {
return status;
}
public void setStatus(Status status) {
this.status = status;
}
}

View file

@ -0,0 +1,99 @@
package com.keylesspalace.tusky;
import android.content.Context;
import android.support.v7.widget.RecyclerView;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import java.util.ArrayList;
import java.util.List;
public class NotificationsAdapter extends RecyclerView.Adapter {
private List<Notification> notifications = new ArrayList<>();
@Override
public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
View view = LayoutInflater.from(parent.getContext())
.inflate(R.layout.item_notification, parent, false);
return new ViewHolder(view);
}
@Override
public void onBindViewHolder(RecyclerView.ViewHolder viewHolder, int position) {
ViewHolder holder = (ViewHolder) viewHolder;
Notification notification = notifications.get(position);
holder.setMessage(notification.getType(), notification.getDisplayName());
}
@Override
public int getItemCount() {
return notifications.size();
}
public Notification getItem(int position) {
return notifications.get(position);
}
public int update(List<Notification> new_notifications) {
int scrollToPosition;
if (notifications == null || notifications.isEmpty()) {
notifications = new_notifications;
scrollToPosition = 0;
} else {
int index = new_notifications.indexOf(notifications.get(0));
if (index == -1) {
notifications.addAll(0, new_notifications);
scrollToPosition = 0;
} else {
notifications.addAll(0, new_notifications.subList(0, index));
scrollToPosition = index;
}
}
notifyDataSetChanged();
return scrollToPosition;
}
public void addItems(List<Notification> new_notifications) {
int end = notifications.size();
notifications.addAll(new_notifications);
notifyItemRangeInserted(end, new_notifications.size());
}
public static class ViewHolder extends RecyclerView.ViewHolder {
private TextView message;
public ViewHolder(View itemView) {
super(itemView);
message = (TextView) itemView.findViewById(R.id.notification_text);
}
public void setMessage(Notification.Type type, String displayName) {
Context context = message.getContext();
String wholeMessage = "";
switch (type) {
case MENTION: {
wholeMessage = displayName + " mentioned you";
break;
}
case REBLOG: {
String format = context.getString(R.string.notification_reblog_format);
wholeMessage = String.format(format, displayName);
break;
}
case FAVOURITE: {
String format = context.getString(R.string.notification_favourite_format);
wholeMessage = String.format(format, displayName);
break;
}
case FOLLOW: {
String format = context.getString(R.string.notification_follow_format);
wholeMessage = String.format(format, displayName);
break;
}
}
message.setText(wholeMessage);
}
}
}

View file

@ -0,0 +1,157 @@
package com.keylesspalace.tusky;
import android.content.Context;
import android.content.SharedPreferences;
import android.graphics.drawable.Drawable;
import android.os.Bundle;
import android.support.annotation.Nullable;
import android.support.v4.app.Fragment;
import android.support.v4.content.ContextCompat;
import android.support.v4.widget.SwipeRefreshLayout;
import android.support.v7.widget.DividerItemDecoration;
import android.support.v7.widget.LinearLayoutManager;
import android.support.v7.widget.RecyclerView;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Toast;
import com.android.volley.AuthFailureError;
import com.android.volley.Response;
import com.android.volley.VolleyError;
import com.android.volley.toolbox.JsonArrayRequest;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class NotificationsFragment extends Fragment implements
SwipeRefreshLayout.OnRefreshListener {
private String domain = null;
private String accessToken = null;
private SwipeRefreshLayout swipeRefreshLayout;
private NotificationsAdapter adapter;
public static NotificationsFragment newInstance() {
NotificationsFragment fragment = new NotificationsFragment();
Bundle arguments = new Bundle();
fragment.setArguments(arguments);
return fragment;
}
@Nullable
@Override
public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container,
@Nullable Bundle savedInstanceState) {
View rootView = inflater.inflate(R.layout.fragment_timeline, container, false);
Context context = getContext();
SharedPreferences preferences = context.getSharedPreferences(
getString(R.string.preferences_file_key), Context.MODE_PRIVATE);
domain = preferences.getString("domain", null);
accessToken = preferences.getString("accessToken", null);
assert(domain != null);
assert(accessToken != null);
// Setup the SwipeRefreshLayout.
swipeRefreshLayout = (SwipeRefreshLayout) rootView.findViewById(R.id.swipe_refresh_layout);
swipeRefreshLayout.setOnRefreshListener(this);
// Setup the RecyclerView.
RecyclerView recyclerView = (RecyclerView) rootView.findViewById(R.id.recycler_view);
recyclerView.setHasFixedSize(true);
LinearLayoutManager layoutManager = new LinearLayoutManager(context);
recyclerView.setLayoutManager(layoutManager);
DividerItemDecoration divider = new DividerItemDecoration(
context, layoutManager.getOrientation());
Drawable drawable = ContextCompat.getDrawable(context, R.drawable.status_divider);
divider.setDrawable(drawable);
recyclerView.addItemDecoration(divider);
EndlessOnScrollListener scrollListener = new EndlessOnScrollListener(layoutManager) {
@Override
public void onLoadMore(int page, int totalItemsCount, RecyclerView view) {
NotificationsAdapter adapter = (NotificationsAdapter) view.getAdapter();
String fromId = adapter.getItem(adapter.getItemCount() - 1).getId();
sendFetchNotificationsRequest(fromId);
}
};
recyclerView.addOnScrollListener(scrollListener);
adapter = new NotificationsAdapter();
recyclerView.setAdapter(adapter);
sendFetchNotificationsRequest();
return rootView;
}
private void sendFetchNotificationsRequest(final String fromId) {
String endpoint = getString(R.string.endpoint_notifications);
String url = "https://" + domain + endpoint;
if (fromId != null) {
url += "?max_id=" + fromId;
}
JsonArrayRequest request = new JsonArrayRequest(url,
new Response.Listener<JSONArray>() {
@Override
public void onResponse(JSONArray response) {
List<Notification> notifications = new ArrayList<>();
try {
for (int i = 0; i < response.length(); i++) {
JSONObject object = response.getJSONObject(i);
String id = object.getString("id");
Notification.Type type = Notification.Type.valueOf(
object.getString("type").toUpperCase());
JSONObject account = object.getJSONObject("account");
String displayName = account.getString("display_name");
Notification notification = new Notification(type, id, displayName);
notifications.add(notification);
}
onFetchNotificationsSuccess(notifications, fromId != null);
} catch (JSONException e) {
onFetchNotificationsFailure(e);
}
}
}, new Response.ErrorListener() {
@Override
public void onErrorResponse(VolleyError error) {
onFetchNotificationsFailure(error);
}
}) {
@Override
public Map<String, String> getHeaders() throws AuthFailureError {
Map<String, String> headers = new HashMap<>();
headers.put("Authorization", "Bearer " + accessToken);
return headers;
}
};
VolleySingleton.getInstance(getContext()).addToRequestQueue(request);
}
private void sendFetchNotificationsRequest() {
sendFetchNotificationsRequest(null);
}
private void onFetchNotificationsSuccess(List<Notification> notifications, boolean added) {
if (added) {
adapter.addItems(notifications);
} else {
adapter.update(notifications);
}
swipeRefreshLayout.setRefreshing(false);
}
private void onFetchNotificationsFailure(Exception exception) {
Toast.makeText(getContext(), R.string.error_fetching_notifications, Toast.LENGTH_SHORT)
.show();
swipeRefreshLayout.setRefreshing(false);
}
@Override
public void onRefresh() {
sendFetchNotificationsRequest();
}
}

View file

@ -1,11 +1,28 @@
package com.keylesspalace.tusky; package com.keylesspalace.tusky;
import android.os.Build;
import android.text.Html;
import android.text.Spanned; import android.text.Spanned;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date; import java.util.Date;
import java.util.List;
public class Status { public class Status {
enum Visibility {
PUBLIC,
UNLISTED,
PRIVATE,
}
private String id; private String id;
private String accountId;
private String displayName; private String displayName;
/** the username with the remote domain appended, like @domain.name, if it's a remote account */ /** the username with the remote domain appended, like @domain.name, if it's a remote account */
private String username; private String username;
@ -16,21 +33,35 @@ public class Status {
private String rebloggedByUsername; private String rebloggedByUsername;
/** when the status was initially created */ /** when the status was initially created */
private Date createdAt; private Date createdAt;
/** whether the authenticated user has reblogged this status */
private boolean reblogged;
/** whether the authenticated user has favourited this status */
private boolean favourited;
private Visibility visibility;
public Status(String id, String displayName, String username, Spanned content, String avatar, public Status(String id, String accountId, String displayName, String username, Spanned content,
Date createdAt) { String avatar, Date createdAt, boolean reblogged, boolean favourited,
String visibility) {
this.id = id; this.id = id;
this.accountId = accountId;
this.displayName = displayName; this.displayName = displayName;
this.username = username; this.username = username;
this.content = content; this.content = content;
this.avatar = avatar; this.avatar = avatar;
this.createdAt = createdAt; this.createdAt = createdAt;
this.reblogged = reblogged;
this.favourited = favourited;
this.visibility = Visibility.valueOf(visibility.toUpperCase());
} }
public String getId() { public String getId() {
return id; return id;
} }
public String getAccountId() {
return accountId;
}
public String getDisplayName() { public String getDisplayName() {
return displayName; return displayName;
} }
@ -55,10 +86,30 @@ public class Status {
return rebloggedByUsername; return rebloggedByUsername;
} }
public boolean getReblogged() {
return reblogged;
}
public boolean getFavourited() {
return favourited;
}
public Visibility getVisibility() {
return visibility;
}
public void setRebloggedByUsername(String name) { public void setRebloggedByUsername(String name) {
rebloggedByUsername = name; rebloggedByUsername = name;
} }
public void setReblogged(boolean reblogged) {
this.reblogged = reblogged;
}
public void setFavourited(boolean favourited) {
this.favourited = favourited;
}
@Override @Override
public int hashCode() { public int hashCode() {
return id.hashCode(); return id.hashCode();
@ -74,4 +125,83 @@ public class Status {
Status status = (Status) other; Status status = (Status) other;
return status.id.equals(this.id); return status.id.equals(this.id);
} }
private static Date parseDate(String dateTime) {
Date date;
String s = dateTime.replace("Z", "+00:00");
SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSZ");
try {
date = format.parse(s);
} catch (ParseException e) {
e.printStackTrace();
return null;
}
return date;
}
private static CharSequence trimTrailingWhitespace(CharSequence s) {
int i = s.length();
do {
i--;
} while (i >= 0 && Character.isWhitespace(s.charAt(i)));
return s.subSequence(0, i + 1);
}
private static Spanned compatFromHtml(String html) {
Spanned result;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
result = Html.fromHtml(html, Html.FROM_HTML_MODE_LEGACY);
} else {
result = Html.fromHtml(html);
}
/* Html.fromHtml returns trailing whitespace if the html ends in a </p> tag, which
* all status contents do, so it should be trimmed. */
return (Spanned) trimTrailingWhitespace(result);
}
public static Status parse(JSONObject object, boolean isReblog) throws JSONException {
String id = object.getString("id");
String content = object.getString("content");
Date createdAt = parseDate(object.getString("created_at"));
boolean reblogged = object.getBoolean("reblogged");
boolean favourited = object.getBoolean("favourited");
String visibility = object.getString("visibility");
JSONObject account = object.getJSONObject("account");
String accountId = account.getString("id");
String displayName = account.getString("display_name");
String username = account.getString("acct");
String avatar = account.getString("avatar");
Status reblog = null;
/* This case shouldn't be hit after the first recursion at all. But if this method is
* passed unusual data this check will prevent extra recursion */
if (!isReblog) {
JSONObject reblogObject = object.optJSONObject("reblog");
if (reblogObject != null) {
reblog = parse(reblogObject, true);
}
}
Status status;
if (reblog != null) {
status = reblog;
status.setRebloggedByUsername(username);
} else {
Spanned contentPlus = compatFromHtml(content);
status = new Status(
id, accountId, displayName, username, contentPlus, avatar, createdAt,
reblogged, favourited, visibility);
}
return status;
}
public static List<Status> parse(JSONArray array) throws JSONException {
List<Status> statuses = new ArrayList<>();
for (int i = 0; i < array.length(); i++) {
JSONObject object = array.getJSONObject(i);
statuses.add(parse(object, false));
}
return statuses;
}
} }

View file

@ -0,0 +1,9 @@
package com.keylesspalace.tusky;
import android.view.View;
public interface StatusActionListener {
void onReblog(final boolean reblog, final int position);
void onFavourite(final boolean favourite, final int position);
void onMore(View view, final int position);
}

View file

@ -1,13 +1,13 @@
package com.keylesspalace.tusky; package com.keylesspalace.tusky;
import android.content.Context; import android.content.Context;
import android.graphics.Bitmap;
import android.support.annotation.Nullable; import android.support.annotation.Nullable;
import android.support.v7.widget.RecyclerView; import android.support.v7.widget.RecyclerView;
import android.text.Spanned; import android.text.Spanned;
import android.view.LayoutInflater; import android.view.LayoutInflater;
import android.view.View; import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
import android.widget.ImageButton;
import android.widget.ImageView; import android.widget.ImageView;
import android.widget.TextView; import android.widget.TextView;
@ -21,14 +21,12 @@ import java.util.List;
public class TimelineAdapter extends RecyclerView.Adapter { public class TimelineAdapter extends RecyclerView.Adapter {
private List<Status> statuses = new ArrayList<>(); private List<Status> statuses = new ArrayList<>();
/* StatusActionListener listener;
TootActionListener listener;
public TimelineAdapter(TootActionListener listener) { public TimelineAdapter(StatusActionListener listener) {
super(); super();
this.listener = listener; this.listener = listener;
} }
*/
@Override @Override
public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup viewGroup, int viewType) { public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup viewGroup, int viewType) {
@ -47,13 +45,18 @@ public class TimelineAdapter extends RecyclerView.Adapter {
holder.setContent(status.getContent()); holder.setContent(status.getContent());
holder.setAvatar(status.getAvatar()); holder.setAvatar(status.getAvatar());
holder.setContent(status.getContent()); holder.setContent(status.getContent());
holder.setReblogged(status.getReblogged());
holder.setFavourited(status.getFavourited());
String rebloggedByUsername = status.getRebloggedByUsername(); String rebloggedByUsername = status.getRebloggedByUsername();
if (rebloggedByUsername == null) { if (rebloggedByUsername == null) {
holder.hideReblogged(); holder.hideRebloggedByUsername();
} else { } else {
holder.setRebloggedByUsername(rebloggedByUsername); holder.setRebloggedByUsername(rebloggedByUsername);
} }
// holder.initButtons(mListener, position); holder.setupButtons(listener, position);
if (status.getVisibility() == Status.Visibility.PRIVATE) {
holder.disableReblogging();
}
} }
@Override @Override
@ -86,6 +89,11 @@ public class TimelineAdapter extends RecyclerView.Adapter {
notifyItemRangeInserted(end, new_statuses.size()); notifyItemRangeInserted(end, new_statuses.size());
} }
public void removeItem(int position) {
statuses.remove(position);
notifyItemRemoved(position);
}
public Status getItem(int position) { public Status getItem(int position) {
return statuses.get(position); return statuses.get(position);
} }
@ -98,6 +106,12 @@ public class TimelineAdapter extends RecyclerView.Adapter {
private NetworkImageView avatar; private NetworkImageView avatar;
private ImageView boostedIcon; private ImageView boostedIcon;
private TextView boostedByUsername; private TextView boostedByUsername;
private ImageButton replyButton;
private ImageButton reblogButton;
private ImageButton favouriteButton;
private ImageButton moreButton;
private boolean favourited;
private boolean reblogged;
public ViewHolder(View itemView) { public ViewHolder(View itemView) {
super(itemView); super(itemView);
@ -108,11 +122,12 @@ public class TimelineAdapter extends RecyclerView.Adapter {
avatar = (NetworkImageView) itemView.findViewById(R.id.status_avatar); avatar = (NetworkImageView) itemView.findViewById(R.id.status_avatar);
boostedIcon = (ImageView) itemView.findViewById(R.id.status_boosted_icon); boostedIcon = (ImageView) itemView.findViewById(R.id.status_boosted_icon);
boostedByUsername = (TextView) itemView.findViewById(R.id.status_boosted); boostedByUsername = (TextView) itemView.findViewById(R.id.status_boosted);
/* replyButton = (ImageButton) itemView.findViewById(R.id.status_reply);
mReplyButton = (ImageButton) itemView.findViewById(R.id.reply); reblogButton = (ImageButton) itemView.findViewById(R.id.status_reblog);
mRetweetButton = (ImageButton) itemView.findViewById(R.id.retweet); favouriteButton = (ImageButton) itemView.findViewById(R.id.status_favourite);
mFavoriteButton = (ImageButton) itemView.findViewById(R.id.favorite); moreButton = (ImageButton) itemView.findViewById(R.id.status_more);
*/ reblogged = false;
favourited = false;
} }
public void setDisplayName(String name) { public void setDisplayName(String name) {
@ -177,7 +192,7 @@ public class TimelineAdapter extends RecyclerView.Adapter {
long now = new Date().getTime(); long now = new Date().getTime();
readout = getRelativeTimeSpanString(then, now); readout = getRelativeTimeSpanString(then, now);
} else { } else {
readout = "?m"; readout = "?m"; // unknown minutes~
} }
sinceCreated.setText(readout); sinceCreated.setText(readout);
} }
@ -191,9 +206,53 @@ public class TimelineAdapter extends RecyclerView.Adapter {
boostedByUsername.setVisibility(View.VISIBLE); boostedByUsername.setVisibility(View.VISIBLE);
} }
public void hideReblogged() { public void hideRebloggedByUsername() {
boostedIcon.setVisibility(View.GONE); boostedIcon.setVisibility(View.GONE);
boostedByUsername.setVisibility(View.GONE); boostedByUsername.setVisibility(View.GONE);
} }
public void setReblogged(boolean reblogged) {
this.reblogged = reblogged;
if (!reblogged) {
reblogButton.setImageResource(R.drawable.ic_reblog_off);
} else {
reblogButton.setImageResource(R.drawable.ic_reblog_on);
}
}
public void disableReblogging() {
reblogButton.setEnabled(false);
reblogButton.setImageResource(R.drawable.ic_reblog_disabled);
}
public void setFavourited(boolean favourited) {
this.favourited = favourited;
if (!favourited) {
favouriteButton.setImageResource(R.drawable.ic_favourite_off);
} else {
favouriteButton.setImageResource(R.drawable.ic_favourite_on);
}
}
public void setupButtons(final StatusActionListener listener, final int position) {
reblogButton.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
listener.onReblog(!reblogged, position);
}
});
favouriteButton.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
listener.onFavourite(!favourited, position);
}
});
moreButton.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
listener.onMore(v, position);
}
});
}
} }
} }

View file

@ -0,0 +1,315 @@
package com.keylesspalace.tusky;
import android.content.Context;
import android.content.SharedPreferences;
import android.graphics.drawable.Drawable;
import android.os.Bundle;
import android.support.annotation.Nullable;
import android.support.v4.app.Fragment;
import android.support.v4.content.ContextCompat;
import android.support.v4.widget.SwipeRefreshLayout;
import android.support.v7.widget.DividerItemDecoration;
import android.support.v7.widget.LinearLayoutManager;
import android.support.v7.widget.PopupMenu;
import android.support.v7.widget.RecyclerView;
import android.view.LayoutInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Toast;
import com.android.volley.AuthFailureError;
import com.android.volley.Request;
import com.android.volley.Response;
import com.android.volley.VolleyError;
import com.android.volley.toolbox.JsonArrayRequest;
import com.android.volley.toolbox.JsonObjectRequest;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class TimelineFragment extends Fragment implements
SwipeRefreshLayout.OnRefreshListener, StatusActionListener {
public enum Kind {
HOME,
MENTIONS,
PUBLIC,
}
private String domain = null;
private String accessToken = null;
private String userAccountId = null;
private SwipeRefreshLayout swipeRefreshLayout;
private TimelineAdapter adapter;
private Kind kind;
public static TimelineFragment newInstance(Kind kind) {
TimelineFragment fragment = new TimelineFragment();
Bundle arguments = new Bundle();
arguments.putString("kind", kind.name());
fragment.setArguments(arguments);
return fragment;
}
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container,
Bundle savedInstanceState) {
kind = Kind.valueOf(getArguments().getString("kind"));
View rootView = inflater.inflate(R.layout.fragment_timeline, container, false);
Context context = getContext();
SharedPreferences preferences = context.getSharedPreferences(
getString(R.string.preferences_file_key), Context.MODE_PRIVATE);
domain = preferences.getString("domain", null);
accessToken = preferences.getString("accessToken", null);
assert(domain != null);
assert(accessToken != null);
// Setup the SwipeRefreshLayout.
swipeRefreshLayout = (SwipeRefreshLayout) rootView.findViewById(R.id.swipe_refresh_layout);
swipeRefreshLayout.setOnRefreshListener(this);
// Setup the RecyclerView.
RecyclerView recyclerView = (RecyclerView) rootView.findViewById(R.id.recycler_view);
recyclerView.setHasFixedSize(true);
LinearLayoutManager layoutManager = new LinearLayoutManager(context);
recyclerView.setLayoutManager(layoutManager);
DividerItemDecoration divider = new DividerItemDecoration(
context, layoutManager.getOrientation());
Drawable drawable = ContextCompat.getDrawable(context, R.drawable.status_divider);
divider.setDrawable(drawable);
recyclerView.addItemDecoration(divider);
EndlessOnScrollListener scrollListener = new EndlessOnScrollListener(layoutManager) {
@Override
public void onLoadMore(int page, int totalItemsCount, RecyclerView view) {
TimelineAdapter adapter = (TimelineAdapter) view.getAdapter();
String fromId = adapter.getItem(adapter.getItemCount() - 1).getId();
sendFetchTimelineRequest(fromId);
}
};
recyclerView.addOnScrollListener(scrollListener);
adapter = new TimelineAdapter(this);
recyclerView.setAdapter(adapter);
sendUserInfoRequest();
sendFetchTimelineRequest();
return rootView;
}
private void sendUserInfoRequest() {
sendRequest(Request.Method.GET, getString(R.string.endpoint_verify_credentials), null,
new Response.Listener<JSONObject>() {
@Override
public void onResponse(JSONObject response) {
try {
userAccountId = response.getString("id");
} catch (JSONException e) {
//TODO: Help
assert(false);
}
}
});
}
private void sendFetchTimelineRequest(final String fromId) {
String endpoint;
switch (kind) {
default:
case HOME: {
endpoint = getString(R.string.endpoint_timelines_home);
break;
}
case MENTIONS: {
endpoint = getString(R.string.endpoint_timelines_mentions);
break;
}
case PUBLIC: {
endpoint = getString(R.string.endpoint_timelines_public);
break;
}
}
String url = "https://" + domain + endpoint;
if (fromId != null) {
url += "?max_id=" + fromId;
}
JsonArrayRequest request = new JsonArrayRequest(url,
new Response.Listener<JSONArray>() {
@Override
public void onResponse(JSONArray response) {
List<Status> statuses = null;
try {
statuses = Status.parse(response);
} catch (JSONException e) {
onFetchTimelineFailure(e);
}
if (statuses != null) {
onFetchTimelineSuccess(statuses, fromId != null);
}
}
}, new Response.ErrorListener() {
@Override
public void onErrorResponse(VolleyError error) {
onFetchTimelineFailure(error);
}
}) {
@Override
public Map<String, String> getHeaders() throws AuthFailureError {
Map<String, String> headers = new HashMap<>();
headers.put("Authorization", "Bearer " + accessToken);
return headers;
}
};
VolleySingleton.getInstance(getContext()).addToRequestQueue(request);
}
private void sendFetchTimelineRequest() {
sendFetchTimelineRequest(null);
}
public void onFetchTimelineSuccess(List<Status> statuses, boolean added) {
if (added) {
adapter.addItems(statuses);
} else {
adapter.update(statuses);
}
swipeRefreshLayout.setRefreshing(false);
}
public void onFetchTimelineFailure(Exception exception) {
Toast.makeText(getContext(), R.string.error_fetching_timeline, Toast.LENGTH_SHORT).show();
swipeRefreshLayout.setRefreshing(false);
}
public void onRefresh() {
sendFetchTimelineRequest();
}
private void sendRequest(
int method, String endpoint, JSONObject parameters,
@Nullable Response.Listener<JSONObject> responseListener) {
if (responseListener == null) {
// Use a dummy listener if one wasn't specified so the request can be constructed.
responseListener = new Response.Listener<JSONObject>() {
@Override
public void onResponse(JSONObject response) {}
};
}
String url = "https://" + domain + endpoint;
JsonObjectRequest request = new JsonObjectRequest(
method, url, parameters, responseListener,
new Response.ErrorListener() {
@Override
public void onErrorResponse(VolleyError error) {
System.err.println(error.getMessage());
}
}) {
@Override
public Map<String, String> getHeaders() throws AuthFailureError {
Map<String, String> headers = new HashMap<>();
headers.put("Authorization", "Bearer " + accessToken);
return headers;
}
};
VolleySingleton.getInstance(getContext()).addToRequestQueue(request);
}
private void postRequest(String endpoint) {
sendRequest(Request.Method.POST, endpoint, null, null);
}
public void onReblog(final boolean reblog, final int position) {
final Status status = adapter.getItem(position);
String id = status.getId();
String endpoint;
if (reblog) {
endpoint = String.format(getString(R.string.endpoint_reblog), id);
} else {
endpoint = String.format(getString(R.string.endpoint_unreblog), id);
}
sendRequest(Request.Method.POST, endpoint, null,
new Response.Listener<JSONObject>() {
@Override
public void onResponse(JSONObject response) {
status.setReblogged(reblog);
adapter.notifyItemChanged(position);
}
});
}
public void onFavourite(final boolean favourite, final int position) {
final Status status = adapter.getItem(position);
String id = status.getId();
String endpoint;
if (favourite) {
endpoint = String.format(getString(R.string.endpoint_favourite), id);
} else {
endpoint = String.format(getString(R.string.endpoint_unfavourite), id);
}
sendRequest(Request.Method.POST, endpoint, null, new Response.Listener<JSONObject>() {
@Override
public void onResponse(JSONObject response) {
status.setFavourited(favourite);
adapter.notifyItemChanged(position);
}
});
}
private void follow(String id) {
String endpoint = String.format(getString(R.string.endpoint_follow), id);
postRequest(endpoint);
}
private void block(String id) {
String endpoint = String.format(getString(R.string.endpoint_block), id);
postRequest(endpoint);
}
private void delete(String id) {
String endpoint = String.format(getString(R.string.endpoint_delete), id);
sendRequest(Request.Method.DELETE, endpoint, null, null);
}
public void onMore(View view, final int position) {
Status status = adapter.getItem(position);
final String id = status.getId();
final String accountId = status.getAccountId();
PopupMenu popup = new PopupMenu(getContext(), view);
// Give a different menu depending on whether this is the user's own toot or not.
if (userAccountId == null || !userAccountId.equals(accountId)) {
popup.inflate(R.menu.status_more);
} else {
popup.inflate(R.menu.status_more_for_user);
}
popup.setOnMenuItemClickListener(
new PopupMenu.OnMenuItemClickListener() {
@Override
public boolean onMenuItemClick(MenuItem item) {
switch (item.getItemId()) {
case R.id.status_follow: {
follow(accountId);
return true;
}
case R.id.status_block: {
block(accountId);
return true;
}
case R.id.status_delete: {
delete(id);
adapter.removeItem(position);
return true;
}
}
return false;
}
});
popup.show();
}
}

View file

@ -0,0 +1,45 @@
package com.keylesspalace.tusky;
import android.support.v4.app.Fragment;
import android.support.v4.app.FragmentManager;
import android.support.v4.app.FragmentPagerAdapter;
public class TimelinePagerAdapter extends FragmentPagerAdapter {
private String[] pageTitles;
public TimelinePagerAdapter(FragmentManager manager) {
super(manager);
}
public void setPageTitles(String[] titles) {
pageTitles = titles;
}
@Override
public Fragment getItem(int i) {
switch (i) {
case 0: {
return TimelineFragment.newInstance(TimelineFragment.Kind.HOME);
}
case 1: {
return NotificationsFragment.newInstance();
}
case 2: {
return TimelineFragment.newInstance(TimelineFragment.Kind.PUBLIC);
}
default: {
return null;
}
}
}
@Override
public int getCount() {
return 3;
}
@Override
public CharSequence getPageTitle(int position) {
return pageTitles[position];
}
}

View file

@ -0,0 +1,11 @@
<vector android:height="24dp" android:viewportHeight="708.66144"
android:viewportWidth="708.66144" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillAlpha="1" android:fillColor="#ffffff"
android:pathData="m86.4,17.7c-37.3,0 -67.3,30 -67.3,67.3l0,538.6c0,37.3 30,67.3 67.3,67.3l537.2,0c37.3,0 67.3,-30 67.3,-67.3l0,-462.4c-17.6,17.9 -35.4,35.6 -53.2,53.3l0,352.4c0,39.3 -31.6,70.9 -70.9,70.9l-425.2,0c-39.3,0 -70.9,-31.6 -70.9,-70.9l0,-425.2c0,-39.3 31.6,-70.9 70.9,-70.9l358.9,0c18,-17.7 36,-35.3 53.8,-53.2l-468,0z"
android:strokeAlpha="1" android:strokeColor="#ffffff"
android:strokeLineCap="butt" android:strokeLineJoin="round" android:strokeWidth="33.62945938"/>
<path android:fillAlpha="1" android:fillColor="#ffffff"
android:pathData="m672.8,8.2 l25.1,25.1c13.9,13.9 13.9,36.2 0,50.1L361.6,420.4C347.1,434.2 199.5,537.2 185.6,523.3l-0.8,-0.8C170.9,508.6 272.1,359.2 286.6,344.7L622.7,8.2c13.9,-13.9 36.2,-13.9 50.1,0z"
android:strokeAlpha="1" android:strokeColor="#cccccc"
android:strokeLineCap="butt" android:strokeLineJoin="round" android:strokeWidth="0"/>
</vector>

View file

@ -0,0 +1,15 @@
<vector android:height="24dp" android:viewportHeight="708.66144"
android:viewportWidth="708.66144" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillAlpha="1" android:fillColor="#4d4d4d"
android:pathData="M177.2,354.3m-70.9,0a70.9,70.9 0,1 1,141.7 0a70.9,70.9 0,1 1,-141.7 0"
android:strokeAlpha="1" android:strokeColor="#6d6d6d"
android:strokeLineCap="butt" android:strokeLineJoin="round" android:strokeWidth="14.17322826"/>
<path android:fillAlpha="1" android:fillColor="#4d4d4d"
android:pathData="M354.3,354.3m-70.9,0a70.9,70.9 0,1 1,141.7 0a70.9,70.9 0,1 1,-141.7 0"
android:strokeAlpha="1" android:strokeColor="#6d6d6d"
android:strokeLineCap="butt" android:strokeLineJoin="round" android:strokeWidth="14.17322826"/>
<path android:fillAlpha="1" android:fillColor="#4d4d4d"
android:pathData="M531.5,354.3m-70.9,0a70.9,70.9 0,1 1,141.7 0a70.9,70.9 0,1 1,-141.7 0"
android:strokeAlpha="1" android:strokeColor="#6d6d6d"
android:strokeLineCap="butt" android:strokeLineJoin="round" android:strokeWidth="14.17322826"/>
</vector>

View file

@ -0,0 +1,7 @@
<vector android:height="24dp" android:viewportHeight="708.66144"
android:viewportWidth="708.66144" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillAlpha="1" android:fillColor="#4d4d4d"
android:pathData="M354.3,33.9 L458.4,244.9 691.2,278.7 522.8,442.9 562.5,674.7 354.3,565.3 146.1,674.7 185.9,442.9 17.5,278.7 250.2,244.9Z"
android:strokeAlpha="1" android:strokeColor="#6d6d6d"
android:strokeLineCap="butt" android:strokeLineJoin="round" android:strokeWidth="33.33507919"/>
</vector>

View file

@ -0,0 +1,7 @@
<vector android:height="24dp" android:viewportHeight="708.66144"
android:viewportWidth="708.66144" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillAlpha="1" android:fillColor="#feef00"
android:pathData="M354.3,33.9 L458.4,244.9 691.2,278.7 522.8,442.9 562.5,674.7 354.3,565.3 146.1,674.7 185.9,442.9 17.5,278.7 250.2,244.9Z"
android:strokeAlpha="1" android:strokeColor="#feef00"
android:strokeLineCap="butt" android:strokeLineJoin="round" android:strokeWidth="33.33507919"/>
</vector>

View file

@ -0,0 +1,11 @@
<vector android:height="24dp" android:viewportHeight="708.66144"
android:viewportWidth="708.66144" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#cccccc"
android:pathData="M141.1,141.7L-0.6,283.5L105.7,283.5L105.7,527.5L176.5,456.7L176.5,283.5L282.8,283.5L141.1,141.7zM213.5,141.7L284.4,212.6L420.6,212.6L491.5,141.7L213.5,141.7zM603.3,182.8L532.4,253.7L532.4,460.6L426.1,460.6L567.9,602.4L709.6,460.6L603.3,460.6L603.3,182.8zM254.6,531.5L183.8,602.4L495.4,602.4L424.6,531.5L254.6,531.5z"
android:strokeAlpha="1" android:strokeColor="#cccccc"
android:strokeLineCap="butt" android:strokeLineJoin="round" android:strokeWidth="15"/>
<path android:fillAlpha="1" android:fillColor="#cccccc"
android:pathData="M680,28.6L680,28.6A35.4,35.4 76.1,0 1,680 78.7L78.7,680A35.4,35.4 90.3,0 1,28.6 680L28.6,680A35.4,35.4 90.3,0 1,28.6 629.9L629.9,28.6A35.4,35.4 76.1,0 1,680 28.6z"
android:strokeAlpha="1" android:strokeColor="#bebebe"
android:strokeLineCap="butt" android:strokeLineJoin="round" android:strokeWidth="0"/>
</vector>

View file

@ -0,0 +1,11 @@
<vector android:height="24dp" android:viewportHeight="708.66144"
android:viewportWidth="708.66144" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#4d4d4d"
android:pathData="m567.9,602.4 l-141.7,-141.7 106.3,0 0,-248 -248,0 -70.9,-70.9 389.8,0 0,318.9 106.3,0z"
android:strokeAlpha="1" android:strokeColor="#6d6d6d"
android:strokeLineCap="butt" android:strokeLineJoin="round" android:strokeWidth="14.17322835"/>
<path android:fillColor="#4d4d4d"
android:pathData="m141.1,141.7 l141.7,141.7 -106.3,0 0,248 248,0 70.9,70.9 -389.8,0 0,-318.9 -106.3,0z"
android:strokeAlpha="1" android:strokeColor="#6d6d6d"
android:strokeLineCap="butt" android:strokeLineJoin="round" android:strokeWidth="14.17322826"/>
</vector>

View file

@ -0,0 +1,11 @@
<vector android:height="24dp" android:viewportHeight="708.66144"
android:viewportWidth="708.66144" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#008080"
android:pathData="m567.9,602.4 l-141.7,-141.7 106.3,0 0,-248 -248,0 -70.9,-70.9 389.8,0 0,318.9 106.3,0z"
android:strokeAlpha="1" android:strokeColor="#3c9b9e"
android:strokeLineCap="butt" android:strokeLineJoin="round" android:strokeWidth="14.173"/>
<path android:fillColor="#008080"
android:pathData="m141.1,141.7 l141.7,141.7 -106.3,0 0,248 248,0 70.9,70.9 -389.8,0 0,-318.9 -106.3,0z"
android:strokeAlpha="1" android:strokeColor="#3c9b9e"
android:strokeLineCap="butt" android:strokeLineJoin="round" android:strokeWidth="14.17322826"/>
</vector>

View file

@ -0,0 +1,7 @@
<vector android:height="24dp" android:viewportHeight="708.66144"
android:viewportWidth="708.66144" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillAlpha="1" android:fillColor="#4d4d4d"
android:pathData="m40.4,348.7c200,-200 200,-200 200,-200l0,120 440,0 0,320 -160,0 0,-160 -280,0 0,120z"
android:strokeAlpha="1" android:strokeColor="#6d6d6d"
android:strokeLineCap="butt" android:strokeLineJoin="round" android:strokeWidth="21.25984252"/>
</vector>

View file

@ -0,0 +1,69 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical" android:layout_width="match_parent"
android:layout_height="match_parent">
<EditText
android:layout_width="match_parent"
android:inputType="textMultiLine"
android:ems="10"
android:gravity="top|left"
android:id="@+id/field_status"
android:contentDescription="What's Happening?"
android:layout_height="wrap_content"
android:layout_weight="1" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<RadioGroup
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:orientation="horizontal"
android:id="@+id/radio_visibility"
android:checkedButton="@+id/radio_public">
<RadioButton
android:text="@string/visibility_public"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:id="@+id/radio_public"
android:layout_weight="1" />
<RadioButton
android:text="@string/visibility_unlisted"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:id="@+id/radio_unlisted"
android:layout_weight="1" />
<RadioButton
android:text="@string/visibility_private"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:id="@+id/radio_private"
android:layout_weight="1" />
</RadioGroup>
<Space
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1" />
<TextView
android:id="@+id/characters_left"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="500" />
<Button
android:text="TOOT"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:id="@+id/button_send" />
</LinearLayout>
</LinearLayout>

View file

@ -19,18 +19,34 @@
android:background="?attr/colorPrimary" android:background="?attr/colorPrimary"
android:elevation="4dp" android:elevation="4dp"
android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar" android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"
app:popupTheme="@style/ThemeOverlay.AppCompat.Light"/> app:popupTheme="@style/ThemeOverlay.AppCompat.Light" />
<android.support.v4.widget.SwipeRefreshLayout <android.support.v4.view.ViewPager
android:id="@+id/swipe_refresh_layout" android:id="@+id/pager"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent"> android:layout_height="match_parent">
<android.support.v7.widget.RecyclerView <android.support.design.widget.TabLayout
android:id="@+id/recycler_view" android:id="@+id/tab_layout"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" /> android:layout_height="wrap_content">
</android.support.v4.widget.SwipeRefreshLayout> <android.support.design.widget.TabItem
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/title_home" />
<android.support.design.widget.TabItem
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/title_notifications" />
<android.support.design.widget.TabItem
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/title_public" />
</android.support.design.widget.TabLayout>
</android.support.v4.view.ViewPager>
</LinearLayout> </LinearLayout>

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<android.support.v4.widget.SwipeRefreshLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/swipe_refresh_layout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<android.support.v7.widget.RecyclerView
android:id="@+id/recycler_view"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</android.support.v4.widget.SwipeRefreshLayout>

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical" android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/notification_text"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</LinearLayout>

View file

@ -76,4 +76,63 @@
android:layout_toEndOf="@+id/status_avatar" android:layout_toEndOf="@+id/status_avatar"
android:layout_below="@+id/status_name_bar" /> android:layout_below="@+id/status_name_bar" />
<LinearLayout
android:orientation="horizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@+id/status_content"
android:layout_toRightOf="@+id/status_avatar"
android:paddingBottom="8dp"
android:paddingTop="8dp">
<ImageButton
app:srcCompat="@drawable/ic_reply"
android:id="@+id/status_reply"
style="?android:attr/borderlessButtonStyle"
android:layout_width="32dp"
android:layout_height="32dp" />
<Space
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1" />
<ImageButton
app:srcCompat="@drawable/ic_reblog_off"
android:id="@+id/status_reblog"
style="?android:attr/borderlessButtonStyle"
android:layout_width="32dp"
android:layout_height="32dp" />
<Space
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1" />
<ImageButton
android:layout_width="32dp"
android:layout_height="32dp"
app:srcCompat="@drawable/ic_favourite_off"
android:id="@+id/status_favourite"
style="?android:attr/borderlessButtonStyle" />
<Space
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1" />
<ImageButton
app:srcCompat="@drawable/ic_extra"
android:id="@+id/status_more"
style="?android:attr/borderlessButtonStyle"
android:layout_width="32dp"
android:layout_height="32dp" />
<Space
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1" />
</LinearLayout>
</RelativeLayout> </RelativeLayout>

View file

@ -1,7 +1,13 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<menu <menu
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/tools"> xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_compose"
android:title="@string/action_compose"
android:icon="@drawable/ic_compose"
app:showAsAction="always" />
<item <item
android:id="@+id/action_logout" android:id="@+id/action_logout"

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item android:id="@+id/status_follow"
android:title="@string/action_follow" />
<item android:title="@string/action_block"
android:id="@+id/status_block" />
</menu>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item android:title="@string/action_delete"
android:id="@+id/status_delete" />
</menu>

View file

@ -5,15 +5,63 @@
<string name="oauth_redirect_host">oauth2redirect</string> <string name="oauth_redirect_host">oauth2redirect</string>
<string name="preferences_file_key">com.keylesspalace.tusky.PREFERENCES</string> <string name="preferences_file_key">com.keylesspalace.tusky.PREFERENCES</string>
<string name="endpoint_status">/api/v1/statuses</string>
<string name="endpoint_media">/api/v1/media</string>
<string name="endpoint_timelines_home">/api/v1/timelines/home</string>
<string name="endpoint_timelines_mentions">/api/v1/timelines/mentions</string>
<string name="endpoint_timelines_public">/api/v1/timelines/public</string>
<string name="endpoint_timelines_tag">/api/v1/timelines/tag/%s</string>
<string name="endpoint_notifications">/api/v1/notifications</string>
<string name="endpoint_follows">/api/v1/follows</string>
<string name="endpoint_get_status">/api/v1/statuses/%s</string>
<string name="endpoint_accounts">/api/v1/accounts/%s</string>
<string name="endpoint_verify_credentials">/api/v1/accounts/verify_credentials</string>
<string name="endpoint_statuses">/api/v1/accounts/%s/statuses</string>
<string name="endpoint_following">/api/v1/accounts/%s/following</string>
<string name="endpoint_followers">/api/v1/accounts/%s/followers</string>
<string name="endpoint_relationships">/api/v1/accounts/relationships</string>
<string name="endpoint_blocks">/api/v1/blocks</string>
<string name="endpoint_favourites">/api/v1/favourites</string>
<string name="endpoint_delete">/api/v1/statuses/%s</string>
<string name="endpoint_reblog">/api/v1/statuses/%s/reblog</string>
<string name="endpoint_unreblog">/api/v1/statuses/%s/unreblog</string>
<string name="endpoint_favourite">/api/v1/statuses/%s/favourite</string>
<string name="endpoint_unfavourite">/api/v1/statuses/%s/unfavourite</string>
<string name="endpoint_context">/api/v1/statuses/%s/context</string>
<string name="endpoint_reblogged_by">/api/v1/statuses/%s/reblogged_by</string>
<string name="endpoint_favourited_by">/api/v1/statuses/%s/favourited_by</string>
<string name="endpoint_follow">/api/v1/accounts/%s/follow</string>
<string name="endpoint_unfollow">/api/v1/accounts/%s/unfollow</string>
<string name="endpoint_block">/api/v1/accounts/%s/block</string>
<string name="endpoint_unblock">/api/v1/accounts/%s/unblock</string>
<string name="endpoint_apps">/api/v1/apps</string>
<string name="endpoint_authorize">/oauth/authorize</string> <string name="endpoint_authorize">/oauth/authorize</string>
<string name="endpoint_token">/oauth/token</string> <string name="endpoint_token">/oauth/token</string>
<string name="endpoint_apps">/api/v1/apps</string>
<string name="endpoint_timelines_home">/api/v1/timelines/home</string>
<string name="error_fetching_timeline">Tusky failed to fetch the timeline.</string> <string name="error_fetching_timeline">Tusky failed to fetch the timeline.</string>
<string name="error_fetching_notifications">Notifications could not be fetched.</string>
<string name="error_compose_character_limit">The toot is too long!</string>
<string name="error_sending_status">The toot failed to be sent.</string>
<string name="title_home">Home</string>
<string name="title_notifications">Notifications</string>
<string name="title_public">Public</string>
<string name="status_username_format">\@%s</string> <string name="status_username_format">\@%s</string>
<string name="status_boosted_format">%s boosted</string> <string name="status_boosted_format">%s boosted</string>
<string name="notification_reblog_format">%s boosted your status</string>
<string name="notification_favourite_format">%s favourited your status</string>
<string name="notification_follow_format">%s followed you</string>
<string name="action_compose">Compose</string>
<string name="action_logout">Log Out</string> <string name="action_logout">Log Out</string>
<string name="action_follow">Follow</string>
<string name="action_block">Block</string>
<string name="action_delete">Delete</string>
<string name="visibility_public">Public</string>
<string name="visibility_private">Private</string>
<string name="visibility_unlisted">Unlisted</string>
</resources> </resources>