package org.gingercal; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Date; import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Map; import android.app.Activity; import android.app.AlertDialog; import android.app.ProgressDialog; import android.content.BroadcastReceiver; import android.content.Context; import android.content.DialogInterface; import android.content.Intent; import android.content.IntentFilter; import android.content.res.Configuration; import android.os.AsyncTask; import android.os.Bundle; import android.view.Menu; import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; import android.widget.AdapterView; import android.widget.BaseAdapter; import android.widget.LinearLayout; import android.widget.ListView; import android.widget.TextView; import org.gingercal.Model.Cal; import org.gingercal.Model.Ev; /** Main screen: a month grid with the selected day's events, or the upcoming-events agenda. */ public class MainActivity extends Activity implements MonthView.Listener { static final int M_SYNC = 1, M_NEW = 2, M_CALS = 3, M_SETTINGS = 4, M_VIEW = 5, M_TODAY = 6; LinearLayout root, body; TextView status, monthTitle; ListView agendaList, dayList; MonthView monthView; final List agendaRows = new ArrayList(), dayRows = new ArrayList(); final Rows agendaAdapter = new Rows(agendaRows), dayAdapter = new Rows(dayRows); boolean askedSetup, monthMode, uiGl; long selectedDay; List monthEvents = new ArrayList(); /** Months outside the synced window, fetched from the server on demand (key: grid start). */ final Map> fetched = new HashMap>(); long fetching = -1; String fetchError; final BroadcastReceiver rx = new BroadcastReceiver() { @Override public void onReceive(Context c, Intent i) { String state = i.getStringExtra("state"); if ("start".equals(state)) { showStatus(); return; } reload(true); String fp = i.getStringExtra("fp"); if (fp != null) { DavException.UntrustedCert u = new DavException.UntrustedCert(fp, i.getStringExtra("subject"), i.getStringExtra("issuer"), i.getStringExtra("expires"), i.getBooleanExtra("mismatch", false)); App.askTrust(MainActivity.this, u, new App.Trusted() { public void trusted(String f) { App.prefs(MainActivity.this).edit().putString("pin", f).commit(); App.requestSync(MainActivity.this, true, false); } }); } } }; @Override protected void onCreate(Bundle b) { super.onCreate(b); requestWindowFeature(1 /* Window.FEATURE_NO_TITLE: every pixel counts at 240x320 */); uiGl = L.gl; monthMode = !"agenda".equals(App.get(this, "view")); selectedDay = ICal.startOfDay(System.currentTimeMillis()); root = App.vbox(this); status = App.text(this, "", 11, 0xFFAAAAAA); int p = App.dp(this, 4); status.setPadding(p * 2, p, p * 2, p); status.setBackgroundColor(0xFF1C1C1C); root.addView(status, new LinearLayout.LayoutParams(-1, -2)); agendaList = list(agendaAdapter, agendaRows); dayList = list(dayAdapter, dayRows); monthView = new MonthView(this); monthView.listener = this; monthView.setMonth(selectedDay); monthView.setSelected(selectedDay); monthTitle = App.text(this, "", 15, 0xFFFFFFFF); monthTitle.setGravity(17 /* Gravity.CENTER */); monthTitle.setOnClickListener(new View.OnClickListener() { public void onClick(View v) { goToday(); } }); buildBody(); setContentView(root); App.scheduleSync(this); } ListView list(Rows adapter, final List rows) { ListView l = new ListView(this); l.setAdapter(adapter); l.setOnItemClickListener(new AdapterView.OnItemClickListener() { public void onItemClick(AdapterView parent, View v, int pos, long id) { Object o = rows.get(pos); if (o instanceof Ev) showEvent((Ev) o); } }); return l; } static void detach(View v) { // Views can only have one parent; LinearLayout is the only container we use. Object parent = v.getParent(); if (parent instanceof LinearLayout) ((LinearLayout) parent).removeView(v); } /** (Re)builds the area under the status line for the current mode and orientation. */ void buildBody() { if (body != null) root.removeView(body); detach(agendaList); detach(dayList); detach(monthView); detach(monthTitle); body = App.vbox(this); if (!monthMode) { body.addView(agendaList, new LinearLayout.LayoutParams(-1, -1)); } else { LinearLayout nav = new LinearLayout(this); nav.setOrientation(LinearLayout.HORIZONTAL); nav.setBackgroundColor(0xFF1C2329); nav.addView(navArrow("\u00ab", -1), new LinearLayout.LayoutParams(App.dp(this, 56), App.dp(this, 34))); nav.addView(monthTitle, new LinearLayout.LayoutParams(0, App.dp(this, 34), 1f)); nav.addView(navArrow("\u00bb", 1), new LinearLayout.LayoutParams(App.dp(this, 56), App.dp(this, 34))); body.addView(nav, new LinearLayout.LayoutParams(-1, -2)); boolean landscape = getResources().getDisplayMetrics().widthPixels > getResources().getDisplayMetrics().heightPixels; if (landscape) { LinearLayout row = new LinearLayout(this); row.setOrientation(LinearLayout.HORIZONTAL); row.addView(monthView, new LinearLayout.LayoutParams(0, -1, 1.25f)); row.addView(dayList, new LinearLayout.LayoutParams(0, -1, 1f)); body.addView(row, new LinearLayout.LayoutParams(-1, -1)); } else { body.addView(monthView, new LinearLayout.LayoutParams(-1, -2)); body.addView(dayList, new LinearLayout.LayoutParams(-1, -1)); } } root.addView(body, new LinearLayout.LayoutParams(-1, -1)); } TextView navArrow(String label, final int delta) { TextView b = App.text(this, label, 20, 0xFF3D8EF0); // « »: Latin-1, always in the 2.3 font b.setGravity(17 /* Gravity.CENTER */); int sel = App.sysDrawable(this, "list_selector_background"); if (sel != 0) b.setBackgroundResource(sel); // press highlight b.setOnClickListener(new View.OnClickListener() { public void onClick(View v) { monthSwiped(delta); } }); return b; } @Override public void onConfigurationChanged(Configuration c) { super.onConfigurationChanged(c); buildBody(); } @Override protected void onResume() { super.onResume(); if (uiGl != L.gl) { // language switched in Settings: rebuild every label finish(); startActivity(new Intent(this, MainActivity.class)); return; } registerReceiver(rx, new IntentFilter(SyncService.STATE)); if (!App.configured(this)) { showStatus(); if (!askedSetup) { askedSetup = true; startActivity(new Intent(this, SettingsActivity.class)); } return; } long now = ICal.startOfDay(System.currentTimeMillis()); if (now != monthView.today) monthView.setMonth(monthView.days[15]); // date rolled over reload(true); long last = App.prefs(this).getLong("lastsync", 0); if (!SyncService.running && System.currentTimeMillis() - last > 15 * ICal.MIN) { App.requestSync(this, true, false); } } @Override protected void onPause() { super.onPause(); unregisterReceiver(rx); } void showStatus() { if (!App.configured(this)) { status.setText(L.t("Not set up yet: press Menu > Settings.")); return; } if (SyncService.running) { status.setText(L.t("Syncing\u2026")); status.setTextColor(0xFFAAAAAA); return; } if (monthMode && fetching >= 0) { status.setText(L.f("Loading %s from the server\u2026", monthTitle.getText())); status.setTextColor(0xFFAAAAAA); return; } if (monthMode && fetchError != null) { status.setText(L.f("Couldn't load this month: %s", fetchError)); status.setTextColor(0xFFFF8A80); return; } long last = App.prefs(this).getLong("lastsync", 0); boolean err = App.prefs(this).getBoolean("error", false); String s = App.get(this, "status"); String when = last == 0 ? L.t("never") : App.day(last) + " " + App.time(this, last); if (err) { status.setText(L.f("Sync failed: %s\nLast good sync: %s", s, when)); status.setTextColor(0xFFFF8A80); } else { status.setText(L.f("Synced %s", when) + (s.length() > 0 ? " \u00b7 " + s : "")); status.setTextColor(0xFFAAAAAA); } } void reload(boolean allowFetch) { if (monthMode) loadMonth(allowFetch); else loadAgenda(); showStatus(); } // -------------------------------------------------------------- agenda void loadAgenda() { agendaRows.clear(); long today = ICal.startOfDay(System.currentTimeMillis()); List evs = Db.get(this).agenda(today, ICal.addDays(today, App.ahead(this) + 1)); long lastDay = -1; for (Ev e : evs) { long d = Math.max(ICal.startOfDay(e.start), today); if (d != lastDay) { agendaRows.add(Long.valueOf(d)); lastDay = d; } agendaRows.add(e); } if (agendaRows.isEmpty() && App.configured(this)) { agendaRows.add(L.t("No upcoming events. Menu > New event, or Menu > Sync.")); } agendaAdapter.notifyDataSetChanged(); } // --------------------------------------------------------------- month void loadMonth(boolean allowFetch) { long gs = monthView.gridStart(), ge = monthView.gridEnd(); monthTitle.setText(L.monthTitle(monthView.days[15])); long wf = App.prefs(this).getLong("win_from", 0), wt = App.prefs(this).getLong("win_to", 0); if (wf > 0 && gs >= wf && ge <= wt) { fetchError = null; showMonthEvents(Db.get(this).agenda(gs, ge)); } else { List cached = fetched.get(Long.valueOf(gs)); showMonthEvents(cached != null ? cached : Db.get(this).agenda(gs, ge)); if (allowFetch && fetching != gs && App.configured(this)) new FetchTask(gs, ge).execute(); } } void showMonthEvents(List evs) { monthEvents = evs; Map> marks = new HashMap>(); long gs = monthView.gridStart(), ge = monthView.gridEnd(); for (Ev e : evs) { long d = Math.max(ICal.startOfDay(e.start), gs); for (int guard = 0; d < ge && guard < 42; guard++) { if (!(occupies(e, d))) break; List l = marks.get(Long.valueOf(d)); if (l == null) { l = new ArrayList(); marks.put(Long.valueOf(d), l); } l.add(Integer.valueOf(e.color)); d = ICal.addDays(d, 1); } } monthView.setMarks(marks); showDay(); } static boolean occupies(Ev e, long d) { return ICal.onDay(e.start, e.end, d); } void showDay() { dayRows.clear(); int n = 0; for (Ev e : monthEvents) { if (occupies(e, selectedDay)) { dayRows.add(e); n++; } } if (n == 0) dayRows.add(L.f("Nothing on %s. Long-press a day to add an event.", App.day(selectedDay))); dayAdapter.notifyDataSetChanged(); } public void daySelected(long day) { selectedDay = day; monthView.setSelected(day); if (!monthView.inMonth(day)) { monthView.setMonth(day); reload(true); } else { showDay(); } } public void dayLongPressed(long day) { daySelected(day); newEvent(day); } public void monthSwiped(int delta) { java.util.Calendar c = java.util.Calendar.getInstance(); c.setTimeInMillis(monthView.days[15]); c.set(java.util.Calendar.DAY_OF_MONTH, 1); c.add(java.util.Calendar.MONTH, delta); long first = c.getTimeInMillis(); monthView.setMonth(first); long today = ICal.startOfDay(System.currentTimeMillis()); selectedDay = monthView.inMonth(today) ? today : ICal.startOfDay(first); monthView.setSelected(selectedDay); fetchError = null; reload(true); } void goToday() { selectedDay = ICal.startOfDay(System.currentTimeMillis()); monthView.setMonth(selectedDay); monthView.setSelected(selectedDay); reload(true); } /** Loads a month outside the synced window straight from the server. */ class FetchTask extends AsyncTask { final long gs, ge; FetchTask(long gs, long ge) { this.gs = gs; this.ge = ge; } @Override protected void onPreExecute() { fetching = gs; fetchError = null; showStatus(); } @Override protected Object doInBackground(Void... v) { Dav d = null; try { d = App.dav(MainActivity.this); List out = new ArrayList(); for (Cal c : Db.get(MainActivity.this).calendarList()) { if (!c.enabled) continue; for (Ev e : d.events(c, gs, ge)) { e.color = c.color; e.writable = c.writable; out.add(e); } } java.util.Collections.sort(out, new java.util.Comparator() { public int compare(Ev a, Ev b) { return a.start < b.start ? -1 : a.start > b.start ? 1 : 0; } }); return out; } catch (Throwable t) { return t; } finally { if (d != null) d.close(); } } @SuppressWarnings("unchecked") @Override protected void onPostExecute(Object r) { if (fetching == gs) fetching = -1; if (r instanceof Throwable) { fetchError = App.describe((Throwable) r); } else { fetched.put(Long.valueOf(gs), (List) r); if (monthMode && monthView.gridStart() == gs) showMonthEvents((List) r); } showStatus(); } } // ---------------------------------------------------------------- menu @Override public boolean onCreateOptionsMenu(Menu m) { icon(m.add(0, M_VIEW, 0, L.t("View")), "ic_menu_month"); icon(m.add(0, M_NEW, 1, L.t("New event")), "ic_menu_add"); icon(m.add(0, M_SYNC, 2, L.t("Sync")), "ic_menu_refresh"); icon(m.add(0, M_TODAY, 3, L.t("Today")), "ic_menu_today"); icon(m.add(0, M_CALS, 4, L.t("Calendars")), "ic_menu_agenda"); icon(m.add(0, M_SETTINGS, 5, L.t("Settings")), "ic_menu_preferences"); return true; } @Override public boolean onPrepareOptionsMenu(Menu m) { MenuItem v = m.findItem(M_VIEW); if (v != null) { v.setTitle(monthMode ? L.t("Agenda") : L.t("Month")); icon(v, monthMode ? "ic_menu_agenda" : "ic_menu_month"); } return true; } void icon(MenuItem i, String name) { int id = App.sysDrawable(this, name); if (id == 0) id = App.sysDrawable(this, "ic_menu_my_calendar"); if (id != 0) i.setIcon(id); } @Override public boolean onOptionsItemSelected(MenuItem item) { switch (item.getItemId()) { case M_VIEW: monthMode = !monthMode; App.prefs(this).edit().putString("view", monthMode ? "month" : "agenda").commit(); buildBody(); reload(true); return true; case M_TODAY: if (monthMode) goToday(); else agendaList.setSelection(0); return true; case M_SYNC: if (App.configured(this)) { fetched.clear(); App.requestSync(this, true, false); } else { startActivity(new Intent(this, SettingsActivity.class)); } return true; case M_NEW: newEvent(monthMode ? selectedDay : -1); return true; case M_CALS: startActivity(new Intent(this, CalendarsActivity.class)); return true; case M_SETTINGS: startActivity(new Intent(this, SettingsActivity.class)); return true; } return false; } void newEvent(long day) { if (writableCalendars().isEmpty()) { App.toast(this, L.t("Sync first: no writable calendar yet.")); return; } Intent i = new Intent(this, EditActivity.class); if (day >= 0) i.putExtra("day", day); fetched.clear(); startActivity(i); } List writableCalendars() { List out = new ArrayList(); for (Cal c : Db.get(this).calendarList()) if (c.enabled && c.writable) out.add(c); return out; } // -------------------------------------------------------------- details void showEvent(final Ev e) { StringBuilder m = new StringBuilder(); m.append(App.day(e.start)).append("\n").append(App.when(this, e)); if (e.location.length() > 0) m.append("\n\n").append(e.location); if (e.descr.length() > 0) m.append("\n\n").append(e.descr); Cal cal = Db.get(this).calendars().get(e.cal); if (cal != null) m.append("\n\n").append(L.f("Calendar: %s", cal.name)); if (e.recurring) m.append("\n\n").append(L.t("Repeating event: edit the series in Nextcloud. Delete removes the whole series.")); AlertDialog.Builder b = new AlertDialog.Builder(this) .setTitle(e.title.length() > 0 ? e.title : L.t("(no title)")) .setMessage(m.toString()) .setNegativeButton(L.t("Close"), null); if (e.writable && !e.recurring) { b.setPositiveButton(L.t("Edit"), new DialogInterface.OnClickListener() { public void onClick(DialogInterface d, int w) { fetched.clear(); startActivity(EditActivity.editIntent(MainActivity.this, e)); } }); } if (e.writable) { b.setNeutralButton(L.t("Delete"), new DialogInterface.OnClickListener() { public void onClick(DialogInterface d, int w) { confirmDelete(e); } }); } b.show(); } void confirmDelete(final Ev e) { new AlertDialog.Builder(this) .setTitle(L.t("Delete event?")) .setMessage(e.recurring ? L.f("This deletes EVERY occurrence of \"%s\".", e.title) : L.f("\"%s\" will be deleted on the server.", e.title)) .setPositiveButton(L.t("Delete"), new DialogInterface.OnClickListener() { public void onClick(DialogInterface d, int w) { new DeleteTask(e).execute(); } }) .setNegativeButton(L.t("Cancel"), null) .show(); } class DeleteTask extends AsyncTask { final Ev e; ProgressDialog pd; DeleteTask(Ev e) { this.e = e; } @Override protected void onPreExecute() { pd = ProgressDialog.show(MainActivity.this, null, L.t("Deleting\u2026")); } @Override protected Throwable doInBackground(Void... v) { Dav d = null; try { d = App.dav(MainActivity.this); d.delete(e.href, e.etag); return null; } catch (Throwable t) { return t; } finally { if (d != null) d.close(); } } @Override protected void onPostExecute(Throwable t) { try { pd.dismiss(); } catch (RuntimeException ignored) { } if (t != null) { new AlertDialog.Builder(MainActivity.this).setTitle(L.t("Delete failed")) .setMessage(App.describe(t)).setPositiveButton(L.t("OK"), null).show(); return; } // Hide it right away; the sync that follows brings the cache in line. List keep = new ArrayList(); for (Ev x : monthEvents) if (!x.href.equals(e.href)) keep.add(x); fetched.clear(); for (int i = agendaRows.size() - 1; i >= 0; i--) { Object o = agendaRows.get(i); if (o instanceof Ev && ((Ev) o).href.equals(e.href)) agendaRows.remove(i); } agendaAdapter.notifyDataSetChanged(); if (monthMode) showMonthEvents(keep); App.requestSync(MainActivity.this, true, false); } } // ------------------------------------------------------------- adapter static class Holder { View bar; TextView time, title, where; } /** Day headers (Long), events (Ev) and hint lines (String). */ class Rows extends BaseAdapter { final List rows; Rows(List rows) { this.rows = rows; } public int getCount() { return rows.size(); } public Object getItem(int p) { return rows.get(p); } public long getItemId(int p) { return p; } @Override public int getViewTypeCount() { return 2; } @Override public int getItemViewType(int p) { return rows.get(p) instanceof Ev ? 1 : 0; } @Override public boolean areAllItemsEnabled() { return false; } @Override public boolean isEnabled(int p) { return rows.get(p) instanceof Ev; } public View getView(int p, View convert, ViewGroup parent) { Object o = rows.get(p); Context c = MainActivity.this; if (!(o instanceof Ev)) { TextView h = convert instanceof TextView ? (TextView) convert : App.text(c, "", 13, 0xFFFFFFFF); int pad = App.dp(c, 5); h.setPadding(pad * 2, pad, pad, pad); boolean header = o instanceof Long; h.setBackgroundColor(header ? 0xFF2E3B44 : 0x00000000); h.setTextColor(header ? 0xFFFFFFFF : 0xFF90A4AE); h.setText(header ? App.dayHeader(((Long) o).longValue()) : String.valueOf(o)); return h; } Ev e = (Ev) o; Holder hd; View v = convert; if (v == null || !(v.getTag() instanceof Holder)) { hd = new Holder(); LinearLayout row = new LinearLayout(c); row.setOrientation(LinearLayout.HORIZONTAL); hd.bar = new View(c); row.addView(hd.bar, new LinearLayout.LayoutParams(App.dp(c, 5), -1)); LinearLayout col = App.vbox(c); int pad = App.dp(c, 5); col.setPadding(pad * 2, pad, pad, pad); hd.time = App.text(c, "", 12, 0xFFB0BEC5); hd.title = App.text(c, "", 16, 0xFFFFFFFF); hd.where = App.text(c, "", 12, 0xFF90A4AE); col.addView(hd.time); col.addView(hd.title); col.addView(hd.where); row.addView(col, new LinearLayout.LayoutParams(-1, -2)); row.setTag(hd); v = row; } else { hd = (Holder) v.getTag(); } hd.bar.setBackgroundColor(e.color); hd.time.setText(App.when(c, e) + (e.recurring ? " " + L.t("(repeats)") : "")); hd.title.setText(e.title.length() > 0 ? e.title : L.t("(no title)")); hd.where.setText(e.location); hd.where.setVisibility(e.location.length() > 0 ? View.VISIBLE : View.GONE); return v; } } }