1
0
Fork 0
mirror of https://github.com/MatomoCamp/matomocamp-companion-android.git synced 2024-09-19 16:13:46 +02:00

Convert the entire app to Kotlin (#50)

- Remove all Java code and replace it with Kotlin equivalent
- Use KTX versions of all libraries to extend them with Kotlin functionality
- Migrate Okio to its latest version which is written in Kotlin.
This commit is contained in:
Christophe Beyls 2020-01-28 23:47:38 +01:00 committed by GitHub
parent afcc269eda
commit 669f5d1cb0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
208 changed files with 7997 additions and 10400 deletions

View file

@ -1,4 +1,7 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
apply plugin: 'kotlin-kapt'
android {
compileSdkVersion 29
@ -8,11 +11,17 @@ android {
applicationId "be.digitalia.fosdem"
minSdkVersion 17
targetSdkVersion 29
versionCode 1700173
versionName "1.7.3"
versionCode 1700200
versionName "2.0.0"
// Supported languages
resConfigs "en"
vectorDrawables.useSupportLibrary = true
javaCompileOptions {
annotationProcessorOptions {
arguments = ["room.incremental": "true"]
}
}
}
buildTypes {
@ -27,30 +36,48 @@ android {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
packagingOptions {
exclude 'kotlin/**'
exclude '**/*.kotlin_metadata'
exclude 'META-INF/*.kotlin_module'
exclude 'META-INF/*.version'
exclude 'META-INF/com.android.tools/**'
}
androidExtensions {
features = ["parcelize"]
}
}
dependencies {
def lifecycle_version = "2.2.0"
def room_version = "2.2.3"
implementation 'androidx.core:core:1.2.0-rc01'
implementation 'androidx.fragment:fragment:1.2.0'
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.3'
implementation 'androidx.core:core-ktx:1.2.0-rc01'
implementation 'androidx.fragment:fragment-ktx:1.2.0'
implementation 'androidx.appcompat:appcompat:1.1.0'
implementation 'com.google.android.material:material:1.1.0-rc02'
implementation 'androidx.coordinatorlayout:coordinatorlayout:1.1.0'
implementation 'androidx.recyclerview:recyclerview:1.1.0'
implementation 'androidx.viewpager2:viewpager2:1.0.0'
implementation 'androidx.drawerlayout:drawerlayout:1.1.0-alpha03'
implementation 'androidx.preference:preference:1.1.0'
implementation 'androidx.preference:preference-ktx:1.1.0'
implementation 'androidx.browser:browser:1.2.0'
implementation "androidx.lifecycle:lifecycle-runtime:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-viewmodel:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-livedata:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycle_version"
implementation 'androidx.paging:paging-runtime:2.1.1'
implementation "androidx.room:room-runtime:$room_version"
annotationProcessor "androidx.room:room-compiler:$room_version"
implementation 'androidx.paging:paging-runtime-ktx:2.1.1'
implementation "androidx.room:room-ktx:$room_version"
kapt "androidx.room:room-compiler:$room_version"
implementation 'com.squareup.okhttp3:okhttp:3.12.8'
implementation 'com.squareup.okio:okio:1.17.5'
implementation 'com.squareup.okio:okio:2.4.3'
implementation 'com.github.chrisbanes:PhotoView:2.3.0'
}

View file

@ -1,24 +0,0 @@
package be.digitalia.fosdem;
import android.app.Application;
import androidx.preference.PreferenceManager;
import be.digitalia.fosdem.alarms.FosdemAlarmManager;
import be.digitalia.fosdem.utils.ThemeManager;
public class FosdemApplication extends Application {
@Override
public void onCreate() {
super.onCreate();
// Initialize settings
PreferenceManager.setDefaultValues(this, R.xml.settings, false);
// Light/Dark theme switch (requires settings)
ThemeManager.init(this);
// Alarms (requires settings)
FosdemAlarmManager.init(this);
}
}

View file

@ -0,0 +1,21 @@
package be.digitalia.fosdem
import android.app.Application
import androidx.preference.PreferenceManager
import be.digitalia.fosdem.alarms.FosdemAlarmManager
import be.digitalia.fosdem.utils.ThemeManager
class FosdemApplication : Application() {
override fun onCreate() {
super.onCreate()
// Initialize settings
PreferenceManager.setDefaultValues(this, R.xml.settings, false)
// Light/Dark theme switch (requires settings)
ThemeManager.init(this)
// Alarms (requires settings)
FosdemAlarmManager.init(this)
}
}

View file

@ -1,160 +0,0 @@
package be.digitalia.fosdem.activities;
import android.content.Intent;
import android.content.res.ColorStateList;
import android.nfc.NdefRecord;
import android.os.Bundle;
import android.widget.ImageButton;
import android.widget.Toast;
import com.google.android.material.appbar.AppBarLayout;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.widget.Toolbar;
import androidx.core.content.ContextCompat;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager;
import androidx.lifecycle.Observer;
import androidx.lifecycle.ViewModelProvider;
import be.digitalia.fosdem.R;
import be.digitalia.fosdem.fragments.EventDetailsFragment;
import be.digitalia.fosdem.model.Event;
import be.digitalia.fosdem.model.Track;
import be.digitalia.fosdem.utils.NfcUtils;
import be.digitalia.fosdem.utils.NfcUtils.CreateNfcAppDataCallback;
import be.digitalia.fosdem.utils.ThemeUtils;
import be.digitalia.fosdem.viewmodels.BookmarkStatusViewModel;
import be.digitalia.fosdem.viewmodels.EventViewModel;
import be.digitalia.fosdem.widgets.BookmarkStatusAdapter;
/**
* Displays a single event passed either as a complete Parcelable object in extras or as an id in data.
*
* @author Christophe Beyls
*/
public class EventDetailsActivity extends AppCompatActivity implements Observer<Event>, CreateNfcAppDataCallback {
public static final String EXTRA_EVENT = "event";
private AppBarLayout appBarLayout;
private Toolbar toolbar;
private BookmarkStatusViewModel bookmarkStatusViewModel;
private Event event;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.single_event);
appBarLayout = findViewById(R.id.appbar);
toolbar = findViewById(R.id.toolbar);
setSupportActionBar(findViewById(R.id.bottom_appbar));
ImageButton floatingActionButton = findViewById(R.id.fab);
final ViewModelProvider viewModelProvider = new ViewModelProvider(this);
bookmarkStatusViewModel = viewModelProvider.get(BookmarkStatusViewModel.class);
BookmarkStatusAdapter.setupWithImageButton(bookmarkStatusViewModel, this, floatingActionButton);
Event event = getIntent().getParcelableExtra(EXTRA_EVENT);
if (event != null) {
// The event has been passed as parameter, it can be displayed immediately
initEvent(event);
if (savedInstanceState == null) {
Fragment f = EventDetailsFragment.newInstance(event);
getSupportFragmentManager().beginTransaction().add(R.id.content, f).commit();
}
} else {
// Load the event from the DB using its id
EventViewModel viewModel = viewModelProvider.get(EventViewModel.class);
if (!viewModel.hasEventId()) {
Intent intent = getIntent();
String eventIdString;
if (NfcUtils.hasAppData(intent)) {
// NFC intent
eventIdString = NfcUtils.toEventIdString((NfcUtils.extractAppData(intent)));
} else {
// Normal in-app intent
eventIdString = intent.getDataString();
}
viewModel.setEventId(Long.parseLong(eventIdString));
}
viewModel.getEvent().observe(this, this);
}
}
@Override
public void onChanged(@Nullable Event event) {
if (event == null) {
// Event not found, quit
Toast.makeText(this, getString(R.string.event_not_found_error), Toast.LENGTH_LONG).show();
finish();
return;
}
initEvent(event);
FragmentManager fm = getSupportFragmentManager();
if (fm.findFragmentById(R.id.content) == null) {
Fragment f = EventDetailsFragment.newInstance(event);
fm.beginTransaction().add(R.id.content, f).commitAllowingStateLoss();
}
}
/**
* Initialize event-related configuration after the event has been loaded.
*/
private void initEvent(@NonNull Event event) {
this.event = event;
// Enable up navigation only after getting the event details
toolbar.setNavigationIcon(R.drawable.abc_ic_ab_back_material);
toolbar.setNavigationContentDescription(R.string.abc_action_bar_up_description);
toolbar.setNavigationOnClickListener(v -> onSupportNavigateUp());
toolbar.setTitle(event.getTrack().getName());
final Track.Type trackType = event.getTrack().getType();
if (ThemeUtils.isLightTheme(this)) {
final ColorStateList trackAppBarColor = ContextCompat.getColorStateList(this, trackType.getAppBarColorResId());
final int trackStatusBarColor = ContextCompat.getColor(this, trackType.getStatusBarColorResId());
ThemeUtils.setActivityColors(this, trackAppBarColor.getDefaultColor(), trackStatusBarColor);
ThemeUtils.tintBackground(appBarLayout, trackAppBarColor);
} else {
final ColorStateList trackTextColor = ContextCompat.getColorStateList(this, trackType.getTextColorResId());
toolbar.setTitleTextColor(trackTextColor);
}
bookmarkStatusViewModel.setEvent(event);
// Enable Android Beam
NfcUtils.setAppDataPushMessageCallbackIfAvailable(this, this);
}
@Nullable
@Override
public Intent getSupportParentActivityIntent() {
// Navigate up to the track associated with this event
return new Intent(this, TrackScheduleActivity.class)
.putExtra(TrackScheduleActivity.EXTRA_DAY, event.getDay())
.putExtra(TrackScheduleActivity.EXTRA_TRACK, event.getTrack())
.putExtra(TrackScheduleActivity.EXTRA_FROM_EVENT_ID, event.getId());
}
@Override
public void supportNavigateUpTo(@NonNull Intent upIntent) {
// Replicate the compatibility implementation of NavUtils.navigateUpTo()
// to ensure the parent Activity is always launched
// even if not present on the back stack.
upIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
startActivity(upIntent);
finish();
}
// CreateNfcAppDataCallback
@Override
public NdefRecord createNfcAppData() {
return NfcUtils.createEventAppData(this, event);
}
}

View file

@ -0,0 +1,135 @@
package be.digitalia.fosdem.activities
import android.content.Intent
import android.nfc.NdefRecord
import android.os.Bundle
import android.view.View
import android.widget.ImageButton
import android.widget.Toast
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.Toolbar
import androidx.core.content.ContextCompat
import androidx.fragment.app.commit
import androidx.lifecycle.observe
import be.digitalia.fosdem.R
import be.digitalia.fosdem.fragments.EventDetailsFragment
import be.digitalia.fosdem.model.Event
import be.digitalia.fosdem.utils.*
import be.digitalia.fosdem.viewmodels.BookmarkStatusViewModel
import be.digitalia.fosdem.viewmodels.EventViewModel
import be.digitalia.fosdem.widgets.setupBookmarkStatus
/**
* Displays a single event passed either as a complete Parcelable object in extras or as an id in data.
*
* @author Christophe Beyls
*/
class EventDetailsActivity : AppCompatActivity(), CreateNfcAppDataCallback {
private val bookmarkStatusViewModel: BookmarkStatusViewModel by viewModels()
private val viewModel: EventViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.single_event)
setSupportActionBar(findViewById(R.id.bottom_appbar))
findViewById<ImageButton>(R.id.fab).setupBookmarkStatus(bookmarkStatusViewModel, this)
val intentEvent: Event? = intent.getParcelableExtra(EXTRA_EVENT)
if (intentEvent != null) {
// The event has been passed as parameter, it can be displayed immediately
initEvent(intentEvent)
if (savedInstanceState == null) {
supportFragmentManager.commit { add(R.id.content, EventDetailsFragment.newInstance(intentEvent)) }
}
} else {
// Load the event from the DB using its id
if (!viewModel.isEventIdSet) {
val intent = intent
val eventIdString = if (intent.hasNfcAppData()) {
// NFC intent
intent.extractNfcAppData().toEventIdString()
} else {
// Normal in-app intent
intent.dataString!!
}
viewModel.setEventId(eventIdString.toLong())
}
viewModel.event.observe(this) { event ->
if (event == null) {
// Event not found, quit
Toast.makeText(this, getString(R.string.event_not_found_error), Toast.LENGTH_LONG).show()
finish()
} else {
initEvent(event)
val fm = supportFragmentManager
if (fm.findFragmentById(R.id.content) == null) {
fm.commit(allowStateLoss = true) { add(R.id.content, EventDetailsFragment.newInstance(event)) }
}
}
}
}
}
/**
* Initialize event-related configuration after the event has been loaded.
*/
private fun initEvent(event: Event) {
// Enable up navigation only after getting the event details
val toolbar = findViewById<Toolbar>(R.id.toolbar).apply {
setNavigationIcon(R.drawable.abc_ic_ab_back_material)
setNavigationContentDescription(R.string.abc_action_bar_up_description)
setNavigationOnClickListener { onSupportNavigateUp() }
title = event.track.name
}
val trackType = event.track.type
if (isLightTheme) {
window.statusBarColorCompat = ContextCompat.getColor(this, trackType.statusBarColorResId)
val trackAppBarColor = ContextCompat.getColorStateList(this, trackType.appBarColorResId)!!
setTaskColorPrimary(trackAppBarColor.defaultColor)
findViewById<View>(R.id.appbar).tintBackground(trackAppBarColor)
} else {
val trackTextColor = ContextCompat.getColorStateList(this, trackType.textColorResId)!!
toolbar.setTitleTextColor(trackTextColor)
}
bookmarkStatusViewModel.event = event
// Enable Android Beam
setNfcAppDataPushMessageCallbackIfAvailable(this)
}
override fun getSupportParentActivityIntent(): Intent? {
val event = bookmarkStatusViewModel.event ?: return null
// Navigate up to the track associated with this event
return Intent(this, TrackScheduleActivity::class.java)
.putExtra(TrackScheduleActivity.EXTRA_DAY, event.day)
.putExtra(TrackScheduleActivity.EXTRA_TRACK, event.track)
.putExtra(TrackScheduleActivity.EXTRA_FROM_EVENT_ID, event.id)
}
override fun supportNavigateUpTo(upIntent: Intent) {
// Replicate the compatibility implementation of NavUtils.navigateUpTo()
// to ensure the parent Activity is always launched
// even if not present on the back stack.
upIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
startActivity(upIntent)
finish()
}
// CreateNfcAppDataCallback
override fun createNfcAppData(): NdefRecord? {
return bookmarkStatusViewModel.event?.toNfcAppData(this)
}
companion object {
const val EXTRA_EVENT = "event"
}
}

View file

@ -1,38 +0,0 @@
package be.digitalia.fosdem.activities;
import android.content.Intent;
import android.os.Bundle;
import androidx.appcompat.app.ActionBar;
import androidx.fragment.app.Fragment;
import be.digitalia.fosdem.R;
import be.digitalia.fosdem.fragments.ExternalBookmarksListFragment;
import be.digitalia.fosdem.utils.NfcUtils;
public class ExternalBookmarksActivity extends SimpleToolbarActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
ActionBar bar = getSupportActionBar();
bar.setDisplayHomeAsUpEnabled(true);
if (savedInstanceState == null) {
Intent intent = getIntent();
long[] bookmarkIds = null;
if (NfcUtils.hasAppData(intent)) {
bookmarkIds = NfcUtils.toBookmarks(NfcUtils.extractAppData(intent));
}
if (bookmarkIds == null) {
// Invalid data format, exit
finish();
return;
}
Fragment f = ExternalBookmarksListFragment.newInstance(bookmarkIds);
getSupportFragmentManager().beginTransaction().add(R.id.content, f).commit();
}
}
}

View file

@ -0,0 +1,33 @@
package be.digitalia.fosdem.activities
import android.os.Bundle
import androidx.fragment.app.commit
import be.digitalia.fosdem.R
import be.digitalia.fosdem.fragments.ExternalBookmarksListFragment
import be.digitalia.fosdem.utils.extractNfcAppData
import be.digitalia.fosdem.utils.hasNfcAppData
import be.digitalia.fosdem.utils.toBookmarks
class ExternalBookmarksActivity : SimpleToolbarActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
if (savedInstanceState == null) {
val intent = intent
val bookmarkIds = if (intent.hasNfcAppData()) {
intent.extractNfcAppData().toBookmarks()
} else null
if (bookmarkIds == null) {
// Invalid data format, exit
finish()
return
}
val f = ExternalBookmarksListFragment.newInstance(bookmarkIds)
supportFragmentManager.commit { add(R.id.content, f) }
}
}
}

View file

@ -1,497 +0,0 @@
package be.digitalia.fosdem.activities;
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.annotation.SuppressLint;
import android.app.SearchManager;
import android.content.ActivityNotFoundException;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.graphics.drawable.Animatable;
import android.graphics.drawable.Drawable;
import android.net.Uri;
import android.nfc.NdefRecord;
import android.os.Build;
import android.os.Bundle;
import android.text.format.DateUtils;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.widget.ProgressBar;
import android.widget.TextView;
import com.google.android.material.navigation.NavigationView;
import com.google.android.material.snackbar.Snackbar;
import androidx.annotation.IdRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.ActionBarDrawerToggle;
import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.widget.SearchView;
import androidx.browser.customtabs.CustomTabsIntent;
import androidx.core.view.ViewCompat;
import androidx.drawerlayout.widget.DrawerLayout;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager;
import androidx.fragment.app.FragmentTransaction;
import androidx.lifecycle.Observer;
import be.digitalia.fosdem.BuildConfig;
import be.digitalia.fosdem.R;
import be.digitalia.fosdem.api.FosdemApi;
import be.digitalia.fosdem.api.FosdemUrls;
import be.digitalia.fosdem.db.AppDatabase;
import be.digitalia.fosdem.fragments.BookmarksListFragment;
import be.digitalia.fosdem.fragments.LiveFragment;
import be.digitalia.fosdem.fragments.MapFragment;
import be.digitalia.fosdem.fragments.PersonsListFragment;
import be.digitalia.fosdem.fragments.TracksFragment;
import be.digitalia.fosdem.livedata.SingleEvent;
import be.digitalia.fosdem.model.DownloadScheduleResult;
import be.digitalia.fosdem.utils.CustomTabsUtils;
import be.digitalia.fosdem.utils.NfcUtils;
/**
* Main entry point of the application. Allows to switch between section fragments and update the database.
*
* @author Christophe Beyls
*/
public class MainActivity extends AppCompatActivity implements NfcUtils.CreateNfcAppDataCallback {
public static final String ACTION_SHORTCUT_BOOKMARKS = BuildConfig.APPLICATION_ID + ".intent.action.SHORTCUT_BOOKMARKS";
public static final String ACTION_SHORTCUT_LIVE = BuildConfig.APPLICATION_ID + ".intent.action.SHORTCUT_LIVE";
private enum Section {
TRACKS(R.id.menu_tracks, true, true) {
@Override
public Fragment createFragment() {
return new TracksFragment();
}
},
BOOKMARKS(R.id.menu_bookmarks, false, false) {
@Override
public Fragment createFragment() {
return new BookmarksListFragment();
}
},
LIVE(R.id.menu_live, true, false) {
@Override
public Fragment createFragment() {
return new LiveFragment();
}
},
SPEAKERS(R.id.menu_speakers, false, false) {
@Override
public Fragment createFragment() {
return new PersonsListFragment();
}
},
MAP(R.id.menu_map, false, false) {
@Override
public Fragment createFragment() {
return new MapFragment();
}
};
private final int menuItemId;
private final boolean extendsAppBar;
private final boolean keep;
Section(@IdRes int menuItemId, boolean extendsAppBar, boolean keep) {
this.menuItemId = menuItemId;
this.extendsAppBar = extendsAppBar;
this.keep = keep;
}
@IdRes
public int getMenuItemId() {
return menuItemId;
}
public abstract Fragment createFragment();
public boolean extendsAppBar() {
return extendsAppBar;
}
public boolean shouldKeep() {
return keep;
}
@Nullable
public static Section fromMenuItemId(@IdRes int menuItemId) {
for (Section section : Section.values()) {
if (section.menuItemId == menuItemId) {
return section;
}
}
return null;
}
}
private static final int ERROR_MESSAGE_DISPLAY_DURATION = 5000;
private static final long DATABASE_VALIDITY_DURATION = DateUtils.DAY_IN_MILLIS;
private static final long AUTO_UPDATE_SNOOZE_DURATION = DateUtils.DAY_IN_MILLIS;
private static final String PREF_LAST_AUTO_UPDATE_TIME = "last_download_reminder_time";
private static final String LAST_UPDATE_DATE_FORMAT = "d MMM yyyy kk:mm:ss";
private View contentView;
// Main menu
Section currentSection;
MenuItem pendingNavigationMenuItem = null;
DrawerLayout drawerLayout;
private ActionBarDrawerToggle drawerToggle;
private NavigationView navigationView;
private TextView lastUpdateTextView;
private MenuItem searchMenuItem;
@SuppressLint("WrongConstant")
private final Observer<SingleEvent<DownloadScheduleResult>> scheduleDownloadResultObserver = singleEvent -> {
final DownloadScheduleResult result = singleEvent.consume();
if (result == null) {
return;
}
final Snackbar snackbar;
if (result.isError()) {
snackbar = Snackbar.make(contentView, R.string.schedule_loading_error, ERROR_MESSAGE_DISPLAY_DURATION)
.setAction(R.string.schedule_loading_retry_action, v -> FosdemApi.downloadSchedule(this));
} else if (result.isUpToDate()) {
snackbar = Snackbar.make(contentView, R.string.events_download_up_to_date, Snackbar.LENGTH_LONG);
} else {
final int eventsCount = result.getEventsCount();
final String message;
if (eventsCount == 0) {
message = getString(R.string.events_download_empty);
} else {
message = getResources().getQuantityString(R.plurals.events_download_completed, eventsCount, eventsCount);
}
snackbar = Snackbar.make(contentView, message, Snackbar.LENGTH_LONG);
}
snackbar.show();
};
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.main);
setSupportActionBar(findViewById(R.id.toolbar));
contentView = findViewById(R.id.content);
// Progress bar setup
final ProgressBar progressBar = findViewById(R.id.progress);
FosdemApi.getDownloadScheduleProgress().observe(this, progressInteger -> {
int progress = progressInteger;
if (progress != 100) {
// Visible
if (progressBar.getVisibility() == View.GONE) {
progressBar.clearAnimation();
progressBar.setVisibility(View.VISIBLE);
}
if (progress == -1) {
progressBar.setIndeterminate(true);
} else {
progressBar.setIndeterminate(false);
progressBar.setProgress(progress);
}
} else {
// Invisible
if (progressBar.getVisibility() == View.VISIBLE) {
// Hide the progress bar with a fill and fade out animation
progressBar.setIndeterminate(false);
progressBar.setProgress(100);
progressBar.animate()
.alpha(0f)
.withLayer()
.setListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
progressBar.setVisibility(View.GONE);
progressBar.setAlpha(1f);
}
});
}
}
});
// Monitor the schedule download result
FosdemApi.getDownloadScheduleResult().observe(this, scheduleDownloadResultObserver);
// Setup drawer layout
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
drawerLayout = findViewById(R.id.drawer_layout);
drawerToggle = new ActionBarDrawerToggle(this, drawerLayout, R.string.main_menu, R.string.close_menu) {
@Override
public void onDrawerStateChanged(int newState) {
super.onDrawerStateChanged(newState);
if (newState == DrawerLayout.STATE_DRAGGING) {
pendingNavigationMenuItem = null;
}
}
@Override
public void onDrawerOpened(View drawerView) {
super.onDrawerOpened(drawerView);
// Make keypad navigation easier
navigationView.requestFocus();
}
@Override
public void onDrawerClosed(View drawerView) {
super.onDrawerClosed(drawerView);
if (pendingNavigationMenuItem != null) {
handleNavigationMenuItem(pendingNavigationMenuItem);
pendingNavigationMenuItem = null;
}
}
};
drawerToggle.setDrawerIndicatorEnabled(true);
drawerLayout.addDrawerListener(drawerToggle);
// Disable drawerLayout focus to allow trackball navigation.
// We handle the drawer closing on back press ourselves.
drawerLayout.setFocusable(false);
// Setup Main menu
navigationView = findViewById(R.id.nav_view);
navigationView.setNavigationItemSelectedListener(menuItem -> {
pendingNavigationMenuItem = menuItem;
drawerLayout.closeDrawer(navigationView);
return true;
});
// Last update date, below the list
lastUpdateTextView = navigationView.findViewById(R.id.last_update);
AppDatabase.getInstance(this).getScheduleDao().getLastUpdateTime()
.observe(this, lastUpdateTimeObserver);
if (savedInstanceState == null) {
// Select initial section
currentSection = Section.TRACKS;
String action = getIntent().getAction();
if (action != null) {
switch (action) {
case ACTION_SHORTCUT_BOOKMARKS:
currentSection = Section.BOOKMARKS;
break;
case ACTION_SHORTCUT_LIVE:
currentSection = Section.LIVE;
break;
}
}
navigationView.setCheckedItem(currentSection.getMenuItemId());
getSupportFragmentManager().beginTransaction()
.add(R.id.content, currentSection.createFragment(), currentSection.name())
.commit();
}
NfcUtils.setAppDataPushMessageCallbackIfAvailable(this, this);
}
@SuppressLint("PrivateResource")
private void updateActionBar(@NonNull Section section, @NonNull MenuItem menuItem) {
setTitle(menuItem.getTitle());
ViewCompat.setTranslationZ(contentView, section.extendsAppBar()
? getResources().getDimension(R.dimen.design_appbar_elevation) : 0f);
}
private final Observer<Long> lastUpdateTimeObserver = new Observer<Long>() {
@Override
public void onChanged(Long lastUpdateTime) {
lastUpdateTextView.setText(getString(R.string.last_update,
(lastUpdateTime == -1L)
? getString(R.string.never)
: android.text.format.DateFormat.format(LAST_UPDATE_DATE_FORMAT, lastUpdateTime)));
}
};
@Override
protected void onPostCreate(Bundle savedInstanceState) {
super.onPostCreate(savedInstanceState);
drawerToggle.syncState();
// Restore current section from NavigationView
final MenuItem menuItem = navigationView.getCheckedItem();
if (menuItem != null) {
if (currentSection == null) {
currentSection = Section.fromMenuItemId(menuItem.getItemId());
}
if (currentSection != null) {
updateActionBar(currentSection, menuItem);
}
}
}
@Override
public void onBackPressed() {
if (drawerLayout.isDrawerOpen(navigationView)) {
drawerLayout.closeDrawer(navigationView);
} else {
super.onBackPressed();
}
}
@Override
protected void onSaveInstanceState(@NonNull Bundle outState) {
// Ensure no fragment transaction attempt will occur after onSaveInstanceState()
if (pendingNavigationMenuItem != null) {
pendingNavigationMenuItem = null;
if (currentSection != null) {
navigationView.setCheckedItem(currentSection.getMenuItemId());
}
}
super.onSaveInstanceState(outState);
}
@Override
protected void onStart() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
if (getDelegate().applyDayNight()) {
recreate();
}
}
super.onStart();
// Scheduled database update
final long now = System.currentTimeMillis();
final Long timeValue = AppDatabase.getInstance(this).getScheduleDao().getLastUpdateTime().getValue();
long time = (timeValue == null) ? -1L : timeValue;
if ((time == -1L) || (time < (now - DATABASE_VALIDITY_DURATION))) {
SharedPreferences prefs = getPreferences(Context.MODE_PRIVATE);
time = prefs.getLong(PREF_LAST_AUTO_UPDATE_TIME, -1L);
if ((time == -1L) || (time < (now - AUTO_UPDATE_SNOOZE_DURATION))) {
prefs.edit()
.putLong(PREF_LAST_AUTO_UPDATE_TIME, now)
.apply();
// Try to update immediately. If it fails, the user gets a message and a retry button.
FosdemApi.downloadSchedule(this);
}
}
}
@Override
protected void onStop() {
if ((searchMenuItem != null) && searchMenuItem.isActionViewExpanded()) {
searchMenuItem.collapseActionView();
}
super.onStop();
}
@Override
public boolean onCreateOptionsMenu(Menu menu) {
getMenuInflater().inflate(R.menu.main, menu);
MenuItem searchMenuItem = menu.findItem(R.id.search);
this.searchMenuItem = searchMenuItem;
searchMenuItem.setOnActionExpandListener(new MenuItem.OnActionExpandListener() {
@Override
public boolean onMenuItemActionExpand(MenuItem item) {
return true;
}
@Override
public boolean onMenuItemActionCollapse(MenuItem item) {
// Workaround for disappearing menu items bug
supportInvalidateOptionsMenu();
return true;
}
});
// Associate searchable configuration with the SearchView
SearchManager searchManager = (SearchManager) getSystemService(Context.SEARCH_SERVICE);
SearchView searchView = (SearchView) searchMenuItem.getActionView();
searchView.setSearchableInfo(searchManager.getSearchableInfo(getComponentName()));
return true;
}
@Override
public boolean onOptionsItemSelected(@NonNull MenuItem item) {
// Will close the drawer if the home button is pressed
if (drawerToggle.onOptionsItemSelected(item)) {
return true;
}
switch (item.getItemId()) {
case R.id.refresh:
Drawable icon = item.getIcon();
if (icon instanceof Animatable) {
// Hack: reset the icon to make sure the MenuItem will redraw itself properly
item.setIcon(icon);
((Animatable) icon).start();
}
FosdemApi.downloadSchedule(this);
return true;
}
return false;
}
// MAIN MENU
void handleNavigationMenuItem(@NonNull MenuItem menuItem) {
final int menuItemId = menuItem.getItemId();
final Section section = Section.fromMenuItemId(menuItemId);
if (section != null) {
selectMenuSection(section, menuItem);
} else {
switch (menuItemId) {
case R.id.menu_settings:
startActivity(new Intent(MainActivity.this, SettingsActivity.class));
overridePendingTransition(R.anim.slide_in_right, R.anim.partial_zoom_out);
break;
case R.id.menu_volunteer:
try {
CustomTabsUtils.configureToolbarColors(new CustomTabsIntent.Builder(), this, R.color.light_color_primary)
.setShowTitle(true)
.build()
.launchUrl(this, Uri.parse(FosdemUrls.getVolunteer()));
} catch (ActivityNotFoundException ignore) {
}
break;
}
}
}
void selectMenuSection(@NonNull Section section, @NonNull MenuItem menuItem) {
if (section != currentSection) {
// Switch to new section
FragmentManager fm = getSupportFragmentManager();
FragmentTransaction ft = fm.beginTransaction();
Fragment f = fm.findFragmentById(R.id.content);
if (f != null) {
if (currentSection.shouldKeep()) {
ft.detach(f);
} else {
ft.remove(f);
}
}
if (section.shouldKeep() && ((f = fm.findFragmentByTag(section.name())) != null)) {
ft.attach(f);
} else {
ft.add(R.id.content, section.createFragment(), section.name());
}
ft.commit();
currentSection = section;
updateActionBar(section, menuItem);
}
}
@Nullable
@Override
public NdefRecord createNfcAppData() {
// Delegate to the currently displayed fragment if it provides NFC data
Fragment f = getSupportFragmentManager().findFragmentById(R.id.content);
if (f instanceof NfcUtils.CreateNfcAppDataCallback) {
return ((NfcUtils.CreateNfcAppDataCallback) f).createNfcAppData();
}
return null;
}
}

View file

@ -0,0 +1,395 @@
package be.digitalia.fosdem.activities
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.annotation.SuppressLint
import android.app.SearchManager
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.graphics.drawable.Animatable
import android.net.Uri
import android.nfc.NdefRecord
import android.os.Build
import android.os.Bundle
import android.text.format.DateFormat
import android.text.format.DateUtils
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.widget.ProgressBar
import android.widget.TextView
import androidx.annotation.IdRes
import androidx.appcompat.app.ActionBarDrawerToggle
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.SearchView
import androidx.browser.customtabs.CustomTabsIntent
import androidx.core.content.edit
import androidx.core.content.getSystemService
import androidx.core.view.ViewCompat
import androidx.core.view.isVisible
import androidx.drawerlayout.widget.DrawerLayout
import androidx.fragment.app.Fragment
import androidx.fragment.app.commit
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.observe
import be.digitalia.fosdem.BuildConfig
import be.digitalia.fosdem.R
import be.digitalia.fosdem.api.FosdemApi
import be.digitalia.fosdem.api.FosdemUrls
import be.digitalia.fosdem.db.AppDatabase
import be.digitalia.fosdem.fragments.*
import be.digitalia.fosdem.model.DownloadScheduleResult
import be.digitalia.fosdem.utils.CreateNfcAppDataCallback
import be.digitalia.fosdem.utils.awaitCloseDrawer
import be.digitalia.fosdem.utils.configureToolbarColors
import be.digitalia.fosdem.utils.setNfcAppDataPushMessageCallbackIfAvailable
import com.google.android.material.navigation.NavigationView
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.CancellationException
/**
* Main entry point of the application. Allows to switch between section fragments and update the database.
*
* @author Christophe Beyls
*/
class MainActivity : AppCompatActivity(), CreateNfcAppDataCallback {
private enum class Section(@IdRes @get:IdRes val menuItemId: Int, val extendsAppBar: Boolean, val keep: Boolean) {
TRACKS(R.id.menu_tracks, true, true) {
override fun createFragment() = TracksFragment()
},
BOOKMARKS(R.id.menu_bookmarks, false, false) {
override fun createFragment() = BookmarksListFragment()
},
LIVE(R.id.menu_live, true, false) {
override fun createFragment() = LiveFragment()
},
SPEAKERS(R.id.menu_speakers, false, false) {
override fun createFragment() = PersonsListFragment()
},
MAP(R.id.menu_map, false, false) {
override fun createFragment() = MapFragment()
};
abstract fun createFragment(): Fragment
companion object {
fun fromMenuItemId(@IdRes menuItemId: Int): Section? {
return values().firstOrNull { it.menuItemId == menuItemId }
}
}
}
private class ViewHolder(val contentView: View,
val drawerLayout: DrawerLayout,
val navigationView: NavigationView)
private lateinit var holder: ViewHolder
private lateinit var drawerToggle: ActionBarDrawerToggle
private var searchMenuItem: MenuItem? = null
private lateinit var currentSection: Section
@SuppressLint("WrongConstant")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.main)
setSupportActionBar(findViewById(R.id.toolbar))
val contentView: View = findViewById(R.id.content)
// Progress bar setup
val progressBar: ProgressBar = findViewById(R.id.progress)
FosdemApi.downloadScheduleProgress.observe(this) { progressValue ->
progressBar.apply {
if (progressValue != 100) {
// Visible
if (!isVisible) {
clearAnimation()
isVisible = true
}
if (progressValue == -1) {
isIndeterminate = true
} else {
isIndeterminate = false
progress = progressValue
}
} else {
// Invisible
if (isVisible) {
// Hide the progress bar with a fill and fade out animation
isIndeterminate = false
progress = 100
animate()
.alpha(0f)
.withLayer()
.setListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
isVisible = false
alpha = 1f
}
})
}
}
}
}
// Monitor the schedule download result
FosdemApi.downloadScheduleResult.observe(this) { singleEvent ->
val result = singleEvent.consume() ?: return@observe
val snackbar = when (result) {
is DownloadScheduleResult.Error -> {
Snackbar.make(contentView, R.string.schedule_loading_error, ERROR_MESSAGE_DISPLAY_DURATION)
.setAction(R.string.schedule_loading_retry_action) { FosdemApi.downloadSchedule(this) }
}
is DownloadScheduleResult.UpToDate -> {
Snackbar.make(contentView, R.string.events_download_up_to_date, Snackbar.LENGTH_LONG)
}
is DownloadScheduleResult.Success -> {
val eventsCount = result.eventsCount
val message = if (eventsCount == 0) {
getString(R.string.events_download_empty)
} else {
resources.getQuantityString(R.plurals.events_download_completed, eventsCount, eventsCount)
}
Snackbar.make(contentView, message, Snackbar.LENGTH_LONG)
}
}
snackbar.show()
}
// Setup drawer layout
supportActionBar?.setDisplayHomeAsUpEnabled(true)
val drawerLayout: DrawerLayout = findViewById(R.id.drawer_layout)
// Disable drawerLayout focus to allow trackball navigation.
// We handle the drawer closing on back press ourselves.
drawerLayout.isFocusable = false
drawerToggle = object : ActionBarDrawerToggle(this, drawerLayout, R.string.main_menu, R.string.close_menu) {
override fun onDrawerOpened(drawerView: View) {
super.onDrawerOpened(drawerView)
// Make keypad navigation easier
holder.navigationView.requestFocus()
}
}.apply {
isDrawerIndicatorEnabled = true
drawerLayout.addDrawerListener(this)
}
// Setup Main menu
val navigationView: NavigationView = findViewById(R.id.nav_view)
navigationView.setNavigationItemSelectedListener { menuItem: MenuItem ->
lifecycleScope.launchWhenStarted {
try {
drawerLayout.awaitCloseDrawer(navigationView)
handleNavigationMenuItem(menuItem)
} catch (e: CancellationException) {
// reset the menu to the current selection
navigationView.setCheckedItem(currentSection.menuItemId)
}
}
true
}
// Latest update date, below the list
val latestUpdateTextView: TextView = navigationView.findViewById(R.id.latest_update)
AppDatabase.getInstance(this).scheduleDao.latestUpdateTime
.observe(this) { time ->
val timeString = if (time == -1L) getString(R.string.never)
else DateFormat.format(LATEST_UPDATE_DATE_FORMAT, time)
latestUpdateTextView.text = getString(R.string.last_update, timeString)
}
holder = ViewHolder(contentView, drawerLayout, navigationView)
if (savedInstanceState == null) {
// Select initial section
val section = when (intent.action) {
ACTION_SHORTCUT_BOOKMARKS -> Section.BOOKMARKS
ACTION_SHORTCUT_LIVE -> Section.LIVE
else -> Section.TRACKS
}
currentSection = section
navigationView.setCheckedItem(section.menuItemId)
supportFragmentManager.commit { add(R.id.content, section.createFragment(), section.name) }
}
setNfcAppDataPushMessageCallbackIfAvailable(this)
}
@SuppressLint("PrivateResource")
private fun updateActionBar(section: Section, menuItem: MenuItem) {
title = menuItem.title
ViewCompat.setTranslationZ(holder.contentView,
if (section.extendsAppBar) resources.getDimension(R.dimen.design_appbar_elevation) else 0f)
}
override fun onPostCreate(savedInstanceState: Bundle?) {
super.onPostCreate(savedInstanceState)
drawerToggle.syncState()
// Restore current section from NavigationView
if (savedInstanceState != null) {
holder.navigationView.checkedItem?.let { menuItem ->
val section = Section.fromMenuItemId(menuItem.itemId)!!
currentSection = section
updateActionBar(section, menuItem)
}
}
}
override fun onBackPressed() {
if (holder.drawerLayout.isDrawerOpen(holder.navigationView)) {
holder.drawerLayout.closeDrawer(holder.navigationView)
} else {
super.onBackPressed()
}
}
override fun onStart() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
if (delegate.applyDayNight()) {
recreate()
}
}
super.onStart()
// Scheduled database update
val now = System.currentTimeMillis()
val latestUpdateTime = AppDatabase.getInstance(this).scheduleDao.latestUpdateTime.value
?: -1L
if (latestUpdateTime == -1L || latestUpdateTime < now - DATABASE_VALIDITY_DURATION) {
val prefs = getPreferences(Context.MODE_PRIVATE)
val latestAttemptTime = prefs.getLong(PREF_LATEST_AUTO_UPDATE_ATTEMPT_TIME, -1L)
if (latestAttemptTime == -1L || latestAttemptTime < now - AUTO_UPDATE_SNOOZE_DURATION) {
prefs.edit {
putLong(PREF_LATEST_AUTO_UPDATE_ATTEMPT_TIME, now)
}
// Try to update immediately. If it fails, the user gets a message and a retry button.
FosdemApi.downloadSchedule(this)
}
}
}
override fun onStop() {
searchMenuItem?.run {
if (isActionViewExpanded) {
collapseActionView()
}
}
super.onStop()
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.main, menu)
this.searchMenuItem = menu.findItem(R.id.search)?.apply {
setOnActionExpandListener(object : MenuItem.OnActionExpandListener {
override fun onMenuItemActionExpand(item: MenuItem): Boolean {
return true
}
override fun onMenuItemActionCollapse(item: MenuItem): Boolean {
// Workaround for disappearing menu items bug
invalidateOptionsMenu()
return true
}
})
// Associate searchable configuration with the SearchView
val searchManager: SearchManager? = getSystemService()
(actionView as SearchView).setSearchableInfo(searchManager?.getSearchableInfo(componentName))
}
return true
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
// Will close the drawer if the home button is pressed
if (drawerToggle.onOptionsItemSelected(item)) {
return true
}
return when (item.itemId) {
R.id.refresh -> {
val icon = item.icon
if (icon is Animatable) {
// Hack: reset the icon to make sure the MenuItem will redraw itself properly
item.icon = icon
icon.start()
}
FosdemApi.downloadSchedule(this)
true
}
else -> false
}
}
// MAIN MENU
private fun handleNavigationMenuItem(menuItem: MenuItem) {
val menuItemId = menuItem.itemId
val section = Section.fromMenuItemId(menuItemId)
if (section != null) {
selectMenuSection(section, menuItem)
} else {
when (menuItemId) {
R.id.menu_settings -> {
startActivity(Intent(this, SettingsActivity::class.java))
overridePendingTransition(R.anim.slide_in_right, R.anim.partial_zoom_out)
}
R.id.menu_volunteer -> try {
CustomTabsIntent.Builder()
.configureToolbarColors(this, R.color.light_color_primary)
.setShowTitle(true)
.build()
.launchUrl(this, Uri.parse(FosdemUrls.volunteer))
} catch (ignore: ActivityNotFoundException) {
}
}
}
}
private fun selectMenuSection(section: Section, menuItem: MenuItem) {
if (section != currentSection) {
// Switch to new section
val fm = supportFragmentManager
fm.commit {
fm.findFragmentById(R.id.content)?.let { currentFragment ->
if (currentSection.keep) {
detach(currentFragment)
} else {
remove(currentFragment)
}
}
val cachedFragment = fm.findFragmentByTag(section.name)
if (section.keep && cachedFragment != null) {
attach(cachedFragment)
} else {
add(R.id.content, section.createFragment(), section.name)
}
}
currentSection = section
updateActionBar(section, menuItem)
}
}
override fun createNfcAppData(): NdefRecord? {
// Delegate to the currently displayed fragment if it provides NFC data
return (supportFragmentManager.findFragmentById(R.id.content) as? CreateNfcAppDataCallback)?.createNfcAppData()
}
companion object {
const val ACTION_SHORTCUT_BOOKMARKS = "${BuildConfig.APPLICATION_ID}.intent.action.SHORTCUT_BOOKMARKS"
const val ACTION_SHORTCUT_LIVE = "${BuildConfig.APPLICATION_ID}.intent.action.SHORTCUT_LIVE"
private const val ERROR_MESSAGE_DISPLAY_DURATION = 5000
private const val DATABASE_VALIDITY_DURATION = DateUtils.DAY_IN_MILLIS
private const val AUTO_UPDATE_SNOOZE_DURATION = DateUtils.DAY_IN_MILLIS
private const val PREF_LATEST_AUTO_UPDATE_ATTEMPT_TIME = "last_download_reminder_time"
private const val LATEST_UPDATE_DATE_FORMAT = "d MMM yyyy kk:mm:ss"
}
}

View file

@ -1,40 +0,0 @@
package be.digitalia.fosdem.activities;
import android.os.Bundle;
import androidx.appcompat.app.ActionBar;
import androidx.appcompat.app.AppCompatActivity;
import androidx.fragment.app.Fragment;
import be.digitalia.fosdem.R;
import be.digitalia.fosdem.fragments.PersonInfoListFragment;
import be.digitalia.fosdem.model.Person;
public class PersonInfoActivity extends AppCompatActivity {
public static final String EXTRA_PERSON = "person";
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.content_extended_title);
setSupportActionBar(findViewById(R.id.toolbar));
Person person = getIntent().getParcelableExtra(EXTRA_PERSON);
ActionBar bar = getSupportActionBar();
bar.setDisplayHomeAsUpEnabled(true);
setTitle(person.getName());
if (savedInstanceState == null) {
Fragment f = PersonInfoListFragment.newInstance(person);
getSupportFragmentManager().beginTransaction().add(R.id.content, f).commit();
}
}
@Override
public boolean onSupportNavigateUp() {
finish();
return true;
}
}

View file

@ -0,0 +1,36 @@
package be.digitalia.fosdem.activities
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.commit
import be.digitalia.fosdem.R
import be.digitalia.fosdem.fragments.PersonInfoListFragment
import be.digitalia.fosdem.model.Person
class PersonInfoActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.content_extended_title)
setSupportActionBar(findViewById(R.id.toolbar))
val person: Person = intent.getParcelableExtra(EXTRA_PERSON)!!
supportActionBar?.setDisplayHomeAsUpEnabled(true)
title = person.name
if (savedInstanceState == null) {
val f = PersonInfoListFragment.newInstance(person)
supportFragmentManager.commit { add(R.id.content, f) }
}
}
override fun onSupportNavigateUp(): Boolean {
finish()
return true
}
companion object {
const val EXTRA_PERSON = "person"
}
}

View file

@ -1,93 +0,0 @@
package be.digitalia.fosdem.activities;
import android.content.ActivityNotFoundException;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.text.SpannableString;
import android.text.Spanned;
import android.text.TextUtils;
import android.text.style.ForegroundColorSpan;
import android.widget.ImageView;
import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.widget.Toolbar;
import androidx.browser.customtabs.CustomTabsIntent;
import androidx.core.content.ContextCompat;
import androidx.lifecycle.LifecycleOwner;
import be.digitalia.fosdem.R;
import be.digitalia.fosdem.api.FosdemApi;
import be.digitalia.fosdem.api.FosdemUrls;
import be.digitalia.fosdem.model.RoomStatus;
import be.digitalia.fosdem.utils.CustomTabsUtils;
import be.digitalia.fosdem.utils.StringUtils;
import be.digitalia.fosdem.utils.ThemeUtils;
/**
* A special Activity which is displayed like a dialog and shows a room image.
* Specify the room name and the room image id as Intent extras.
*
* @author Christophe Beyls
*/
public class RoomImageDialogActivity extends AppCompatActivity {
public static final String EXTRA_ROOM_NAME = "roomName";
public static final String EXTRA_ROOM_IMAGE_RESOURCE_ID = "imageResId";
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
Intent intent = getIntent();
final String roomName = intent.getStringExtra(EXTRA_ROOM_NAME);
setTitle(roomName);
setContentView(R.layout.dialog_room_image);
final ImageView imageView = findViewById(R.id.room_image);
if (!ThemeUtils.isLightTheme(imageView.getContext())) {
ThemeUtils.invertImageColors(imageView);
}
imageView.setImageResource(intent.getIntExtra(EXTRA_ROOM_IMAGE_RESOURCE_ID, 0));
configureToolbar(this, findViewById(R.id.toolbar), roomName);
}
public static void configureToolbar(LifecycleOwner owner,
final Toolbar toolbar, final String roomName) {
toolbar.setTitle(roomName);
if (!TextUtils.isEmpty(roomName)) {
final Context context = toolbar.getContext();
toolbar.inflateMenu(R.menu.room_image_dialog);
toolbar.setOnMenuItemClickListener(item -> {
switch (item.getItemId()) {
case R.id.navigation:
String localNavigationUrl = FosdemUrls.getLocalNavigationToLocation(StringUtils.toSlug(roomName));
try {
CustomTabsUtils.configureToolbarColors(new CustomTabsIntent.Builder(), context, R.color.light_color_primary)
.setShowTitle(true)
.build()
.launchUrl(context, Uri.parse(localNavigationUrl));
} catch (ActivityNotFoundException ignore) {
}
break;
}
return false;
});
// Display the room status as subtitle
FosdemApi.getRoomStatuses(toolbar.getContext()).observe(owner, roomStatuses -> {
RoomStatus roomStatus = roomStatuses.get(roomName);
if (roomStatus != null) {
SpannableString roomNameSpannable = new SpannableString(context.getString(roomStatus.getNameResId()));
int color = ContextCompat.getColor(context, roomStatus.getColorResId());
roomNameSpannable.setSpan(new ForegroundColorSpan(color),
0, roomNameSpannable.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
toolbar.setSubtitle(roomNameSpannable);
} else {
toolbar.setSubtitle(null);
}
});
}
}
}

View file

@ -0,0 +1,88 @@
package be.digitalia.fosdem.activities
import android.content.ActivityNotFoundException
import android.net.Uri
import android.os.Bundle
import android.text.SpannableString
import android.text.style.ForegroundColorSpan
import android.widget.ImageView
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.Toolbar
import androidx.browser.customtabs.CustomTabsIntent
import androidx.core.content.ContextCompat
import androidx.core.text.set
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.observe
import be.digitalia.fosdem.R
import be.digitalia.fosdem.api.FosdemApi
import be.digitalia.fosdem.api.FosdemUrls
import be.digitalia.fosdem.utils.configureToolbarColors
import be.digitalia.fosdem.utils.invertImageColors
import be.digitalia.fosdem.utils.isLightTheme
import be.digitalia.fosdem.utils.toSlug
/**
* A special Activity which is displayed like a dialog and shows a room image.
* Specify the room name and the room image id as Intent extras.
*
* @author Christophe Beyls
*/
class RoomImageDialogActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val intent = intent
val roomName = intent.getStringExtra(EXTRA_ROOM_NAME)!!
title = roomName
setContentView(R.layout.dialog_room_image)
findViewById<ImageView>(R.id.room_image).apply {
if (!context.isLightTheme) {
invertImageColors()
}
setImageResource(intent.getIntExtra(EXTRA_ROOM_IMAGE_RESOURCE_ID, 0))
}
configureToolbar(this, findViewById(R.id.toolbar), roomName)
}
companion object {
const val EXTRA_ROOM_NAME = "roomName"
const val EXTRA_ROOM_IMAGE_RESOURCE_ID = "imageResId"
fun configureToolbar(owner: LifecycleOwner, toolbar: Toolbar, roomName: String) {
toolbar.title = roomName
if (roomName.isNotEmpty()) {
val context = toolbar.context
toolbar.inflateMenu(R.menu.room_image_dialog)
toolbar.setOnMenuItemClickListener { item ->
when (item.itemId) {
R.id.navigation -> {
val localNavigationUrl = FosdemUrls.getLocalNavigationToLocation(roomName.toSlug())
try {
CustomTabsIntent.Builder()
.configureToolbarColors(context, R.color.light_color_primary)
.setShowTitle(true)
.build()
.launchUrl(context, Uri.parse(localNavigationUrl))
} catch (ignore: ActivityNotFoundException) {
}
true
}
else -> false
}
}
// Display the room status as subtitle
FosdemApi.getRoomStatuses(toolbar.context).observe(owner) { roomStatuses ->
val roomStatus = roomStatuses[roomName]
toolbar.subtitle = if (roomStatus != null) {
SpannableString(context.getString(roomStatus.nameResId)).apply {
this[0, length] = ForegroundColorSpan(ContextCompat.getColor(context, roomStatus.colorResId))
}
} else null
}
}
}
}
}

View file

@ -1,116 +0,0 @@
package be.digitalia.fosdem.activities;
import android.app.SearchManager;
import android.content.Context;
import android.content.Intent;
import android.content.res.TypedArray;
import android.os.Bundle;
import android.view.Menu;
import android.view.MenuItem;
import com.google.android.material.snackbar.Snackbar;
import androidx.appcompat.widget.SearchView;
import androidx.lifecycle.ViewModelProvider;
import be.digitalia.fosdem.R;
import be.digitalia.fosdem.fragments.SearchResultListFragment;
import be.digitalia.fosdem.viewmodels.SearchViewModel;
public class SearchResultActivity extends SimpleToolbarActivity {
// Search Intent sent by Google Now
private static final String GMS_ACTION_SEARCH = "com.google.android.gms.actions.SEARCH_ACTION";
private SearchViewModel viewModel;
private SearchView searchView;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
viewModel = new ViewModelProvider(this).get(SearchViewModel.class);
if (savedInstanceState == null) {
SearchResultListFragment f = SearchResultListFragment.newInstance();
getSupportFragmentManager().beginTransaction().replace(R.id.content, f).commit();
handleIntent(getIntent(), false);
}
}
@Override
protected void onNewIntent(Intent intent) {
super.onNewIntent(intent);
handleIntent(intent, true);
}
private void handleIntent(Intent intent, boolean isNewIntent) {
String intentAction = intent.getAction();
if (Intent.ACTION_SEARCH.equals(intentAction) || GMS_ACTION_SEARCH.equals(intentAction)) {
// Normal search, results are displayed here
String query = intent.getStringExtra(SearchManager.QUERY);
if (query == null) {
query = "";
} else {
query = query.trim();
}
if (searchView != null) {
setSearchViewQuery(query);
}
viewModel.setQuery(query);
if (SearchViewModel.isQueryTooShort(query)) {
TypedArray a = getTheme().obtainStyledAttributes(R.styleable.ErrorColors);
int textColor = a.getColor(R.styleable.ErrorColors_colorOnError, 0);
int backgroundColor = a.getColor(R.styleable.ErrorColors_colorError, 0);
a.recycle();
Snackbar.make(findViewById(R.id.content), R.string.search_length_error, Snackbar.LENGTH_LONG)
.setTextColor(textColor)
.setBackgroundTint(backgroundColor)
.show();
}
} else if (Intent.ACTION_VIEW.equals(intentAction)) {
// Search suggestion, dispatch to EventDetailsActivity
Intent dispatchIntent = new Intent(this, EventDetailsActivity.class).setData(intent.getData());
startActivity(dispatchIntent);
if (!isNewIntent) {
finish();
}
}
}
@Override
public boolean onCreateOptionsMenu(Menu menu) {
getMenuInflater().inflate(R.menu.search, menu);
MenuItem searchMenuItem = menu.findItem(R.id.search);
// Associate searchable configuration with the SearchView
SearchManager searchManager = (SearchManager) getSystemService(Context.SEARCH_SERVICE);
searchView = (SearchView) searchMenuItem.getActionView();
searchView.setSearchableInfo(searchManager.getSearchableInfo(getComponentName()));
searchView.setIconifiedByDefault(false); // Always show the search view
setSearchViewQuery(viewModel.getQuery());
return true;
}
private void setSearchViewQuery(String query) {
// Force losing the focus to prevent the suggestions from appearing
searchView.clearFocus();
searchView.setFocusable(false);
searchView.setFocusableInTouchMode(false);
searchView.setQuery(query, false);
}
@Override
public boolean onSupportNavigateUp() {
finish();
return true;
}
}

View file

@ -0,0 +1,106 @@
package be.digitalia.fosdem.activities
import android.app.SearchManager
import android.content.Intent
import android.os.Bundle
import android.view.Menu
import androidx.activity.viewModels
import androidx.appcompat.widget.SearchView
import androidx.core.content.getSystemService
import androidx.fragment.app.commit
import androidx.lifecycle.observe
import be.digitalia.fosdem.R
import be.digitalia.fosdem.fragments.SearchResultListFragment
import be.digitalia.fosdem.viewmodels.SearchViewModel
import be.digitalia.fosdem.viewmodels.SearchViewModel.Result.QueryTooShort
import com.google.android.material.snackbar.Snackbar
class SearchResultActivity : SimpleToolbarActivity() {
private val viewModel: SearchViewModel by viewModels()
private var searchView: SearchView? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
if (savedInstanceState == null) {
supportFragmentManager.commit { add(R.id.content, SearchResultListFragment.newInstance()) }
handleIntent(intent, false)
}
viewModel.results.observe(this) { result ->
if (result is QueryTooShort) {
theme.obtainStyledAttributes(R.styleable.ErrorColors).apply {
val textColor = getColor(R.styleable.ErrorColors_colorOnError, 0)
val backgroundColor = getColor(R.styleable.ErrorColors_colorError, 0)
recycle()
Snackbar.make(findViewById(R.id.content), R.string.search_length_error, Snackbar.LENGTH_LONG)
.setTextColor(textColor)
.setBackgroundTint(backgroundColor)
.show()
}
}
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleIntent(intent, true)
}
private fun handleIntent(intent: Intent, isNewIntent: Boolean) {
when (intent.action) {
Intent.ACTION_SEARCH, GMS_ACTION_SEARCH -> {
// Normal search, results are displayed here
val query = intent.getStringExtra(SearchManager.QUERY)?.trim() ?: ""
searchView?.setQueryWithoutFocus(query)
viewModel.query = query
}
Intent.ACTION_VIEW -> {
// Search suggestion, dispatch to EventDetailsActivity
val dispatchIntent = Intent(this, EventDetailsActivity::class.java).setData(intent.data)
startActivity(dispatchIntent)
if (!isNewIntent) {
finish()
}
}
}
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.search, menu)
menu.findItem(R.id.search)?.apply {
// Associate searchable configuration with the SearchView
val searchManager: SearchManager? = getSystemService()
searchView = (actionView as SearchView).apply {
setSearchableInfo(searchManager?.getSearchableInfo(componentName))
setIconifiedByDefault(false) // Always show the search view
setQueryWithoutFocus(viewModel.query)
}
}
return true
}
private fun SearchView.setQueryWithoutFocus(query: String?) {
// Force losing the focus to prevent the suggestions from appearing
clearFocus()
isFocusable = false
isFocusableInTouchMode = false
setQuery(query, false)
}
override fun onSupportNavigateUp(): Boolean {
finish()
return true
}
companion object {
// Search Intent sent by Google Now
private const val GMS_ACTION_SEARCH = "com.google.android.gms.actions.SEARCH_ACTION"
}
}

View file

@ -1,34 +0,0 @@
package be.digitalia.fosdem.activities;
import android.os.Bundle;
import be.digitalia.fosdem.R;
import be.digitalia.fosdem.fragments.SettingsFragment;
public class SettingsActivity extends SimpleToolbarActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
if (savedInstanceState == null) {
getSupportFragmentManager().beginTransaction()
.add(R.id.content, new SettingsFragment())
.commit();
}
}
@Override
public boolean onSupportNavigateUp() {
onBackPressed();
return true;
}
@Override
public void onBackPressed() {
super.onBackPressed();
overridePendingTransition(R.anim.partial_zoom_in, R.anim.slide_out_right);
}
}

View file

@ -0,0 +1,29 @@
package be.digitalia.fosdem.activities
import android.os.Bundle
import androidx.fragment.app.commit
import be.digitalia.fosdem.R
import be.digitalia.fosdem.fragments.SettingsFragment
class SettingsActivity : SimpleToolbarActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
if (savedInstanceState == null) {
supportFragmentManager.commit { add(R.id.content, SettingsFragment()) }
}
}
override fun onSupportNavigateUp(): Boolean {
onBackPressed()
return true
}
override fun onBackPressed() {
super.onBackPressed()
overridePendingTransition(R.anim.partial_zoom_in, R.anim.slide_out_right)
}
}

View file

@ -1,18 +0,0 @@
package be.digitalia.fosdem.activities;
import android.os.Bundle;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import be.digitalia.fosdem.R;
public abstract class SimpleToolbarActivity extends AppCompatActivity {
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.content);
setSupportActionBar(findViewById(R.id.toolbar));
}
}

View file

@ -0,0 +1,14 @@
package be.digitalia.fosdem.activities
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import be.digitalia.fosdem.R
abstract class SimpleToolbarActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.content)
setSupportActionBar(findViewById(R.id.toolbar))
}
}

View file

@ -1,182 +0,0 @@
package be.digitalia.fosdem.activities;
import android.content.Intent;
import android.content.res.ColorStateList;
import android.nfc.NdefRecord;
import android.os.Bundle;
import android.widget.ImageButton;
import androidx.annotation.Nullable;
import androidx.appcompat.app.ActionBar;
import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.widget.Toolbar;
import androidx.core.content.ContextCompat;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager;
import androidx.fragment.app.FragmentTransaction;
import androidx.lifecycle.ViewModelProvider;
import be.digitalia.fosdem.R;
import be.digitalia.fosdem.fragments.EventDetailsFragment;
import be.digitalia.fosdem.fragments.RoomImageDialogFragment;
import be.digitalia.fosdem.fragments.TrackScheduleListFragment;
import be.digitalia.fosdem.model.Day;
import be.digitalia.fosdem.model.Event;
import be.digitalia.fosdem.model.Track;
import be.digitalia.fosdem.utils.NfcUtils;
import be.digitalia.fosdem.utils.NfcUtils.CreateNfcAppDataCallback;
import be.digitalia.fosdem.utils.ThemeUtils;
import be.digitalia.fosdem.viewmodels.BookmarkStatusViewModel;
import be.digitalia.fosdem.widgets.BookmarkStatusAdapter;
/**
* Track Schedule container, works in both single pane and dual pane modes.
*
* @author Christophe Beyls
*/
public class TrackScheduleActivity extends AppCompatActivity
implements TrackScheduleListFragment.Callbacks, CreateNfcAppDataCallback {
public static final String EXTRA_DAY = "day";
public static final String EXTRA_TRACK = "track";
// Optional extra used as a hint for up navigation from an event
public static final String EXTRA_FROM_EVENT_ID = "from_event_id";
private Day day;
private Track track;
private boolean isTabletLandscape;
private Event lastSelectedEvent;
private BookmarkStatusViewModel bookmarkStatusViewModel = null;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.track_schedule);
final Toolbar toolbar = findViewById(R.id.toolbar);
setSupportActionBar(toolbar);
Bundle extras = getIntent().getExtras();
day = extras.getParcelable(EXTRA_DAY);
track = extras.getParcelable(EXTRA_TRACK);
ActionBar bar = getSupportActionBar();
bar.setDisplayHomeAsUpEnabled(true);
bar.setTitle(track.toString());
bar.setSubtitle(day.toString());
setTitle(String.format("%1$s, %2$s", track.toString(), day.toString()));
final Track.Type trackType = track.getType();
if (ThemeUtils.isLightTheme(this)) {
final ColorStateList trackAppBarColor = ContextCompat.getColorStateList(this, trackType.getAppBarColorResId());
final int trackStatusBarColor = ContextCompat.getColor(this, trackType.getStatusBarColorResId());
ThemeUtils.setActivityColors(this, trackAppBarColor.getDefaultColor(), trackStatusBarColor);
ThemeUtils.tintBackground(toolbar, trackAppBarColor);
} else {
final ColorStateList trackTextColor = ContextCompat.getColorStateList(this, trackType.getTextColorResId());
toolbar.setTitleTextColor(trackTextColor);
}
isTabletLandscape = getResources().getBoolean(R.bool.tablet_landscape);
FragmentManager fm = getSupportFragmentManager();
if (savedInstanceState == null) {
long fromEventId = extras.getLong(EXTRA_FROM_EVENT_ID, -1L);
final TrackScheduleListFragment trackScheduleListFragment;
if (fromEventId != -1L) {
trackScheduleListFragment = TrackScheduleListFragment.newInstance(day, track, fromEventId);
} else {
trackScheduleListFragment = TrackScheduleListFragment.newInstance(day, track);
}
fm.beginTransaction().add(R.id.schedule, trackScheduleListFragment).commit();
} else {
// Cleanup after switching from dual pane to single pane mode
if (!isTabletLandscape) {
FragmentTransaction ft = null;
Fragment eventDetailsFragment = fm.findFragmentById(R.id.event);
if (eventDetailsFragment != null) {
ft = fm.beginTransaction();
ft.remove(eventDetailsFragment);
}
Fragment roomImageDialogFragment = fm.findFragmentByTag(RoomImageDialogFragment.TAG);
if (roomImageDialogFragment != null) {
if (ft == null) {
ft = fm.beginTransaction();
}
ft.remove(roomImageDialogFragment);
}
if (ft != null) {
ft.commit();
}
}
}
if (isTabletLandscape) {
ImageButton floatingActionButton = findViewById(R.id.fab);
if (floatingActionButton != null) {
bookmarkStatusViewModel = new ViewModelProvider(this).get(BookmarkStatusViewModel.class);
BookmarkStatusAdapter.setupWithImageButton(bookmarkStatusViewModel, this, floatingActionButton);
}
// Enable Android Beam
NfcUtils.setAppDataPushMessageCallbackIfAvailable(this, this);
}
}
@Nullable
@Override
public Intent getSupportParentActivityIntent() {
final Intent intent = super.getSupportParentActivityIntent();
// Add FLAG_ACTIVITY_SINGLE_TOP to ensure the Main activity in the back stack is not re-created
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP);
return intent;
}
// TrackScheduleListFragment.Callbacks
@Override
public void onEventSelected(int position, Event event) {
if (isTabletLandscape) {
// Tablet mode: Show event details in the right pane fragment
lastSelectedEvent = event;
FragmentManager fm = getSupportFragmentManager();
EventDetailsFragment currentFragment = (EventDetailsFragment) fm.findFragmentById(R.id.event);
if (event != null) {
// Only replace the fragment if the event is different
if ((currentFragment == null) || !currentFragment.getEvent().equals(event)) {
Fragment f = EventDetailsFragment.newInstance(event);
// Allow state loss since the event fragment will be synchronized with the list selection after activity re-creation
fm.beginTransaction().setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE).replace(R.id.event, f).commitAllowingStateLoss();
}
} else {
// Nothing is selected because the list is empty
if (currentFragment != null) {
fm.beginTransaction().remove(currentFragment).commitAllowingStateLoss();
}
}
if (bookmarkStatusViewModel != null) {
bookmarkStatusViewModel.setEvent(event);
}
} else {
// Classic mode: Show event details in a new activity
Intent intent = new Intent(this, TrackScheduleEventActivity.class);
intent.putExtra(TrackScheduleEventActivity.EXTRA_DAY, day);
intent.putExtra(TrackScheduleEventActivity.EXTRA_TRACK, track);
intent.putExtra(TrackScheduleEventActivity.EXTRA_POSITION, position);
startActivity(intent);
}
}
// CreateNfcAppDataCallback
@Override
public NdefRecord createNfcAppData() {
if (lastSelectedEvent == null) {
return null;
}
return NfcUtils.createEventAppData(this, lastSelectedEvent);
}
}

View file

@ -0,0 +1,157 @@
package be.digitalia.fosdem.activities
import android.content.Intent
import android.nfc.NdefRecord
import android.os.Bundle
import android.widget.ImageButton
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.Toolbar
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentTransaction
import androidx.fragment.app.commit
import be.digitalia.fosdem.R
import be.digitalia.fosdem.fragments.EventDetailsFragment
import be.digitalia.fosdem.fragments.RoomImageDialogFragment
import be.digitalia.fosdem.fragments.TrackScheduleListFragment
import be.digitalia.fosdem.model.Day
import be.digitalia.fosdem.model.Event
import be.digitalia.fosdem.model.Track
import be.digitalia.fosdem.utils.*
import be.digitalia.fosdem.viewmodels.BookmarkStatusViewModel
import be.digitalia.fosdem.widgets.setupBookmarkStatus
/**
* Track Schedule container, works in both single pane and dual pane modes.
*
* @author Christophe Beyls
*/
class TrackScheduleActivity : AppCompatActivity(), TrackScheduleListFragment.Callbacks, CreateNfcAppDataCallback {
private val bookmarkStatusViewModel: BookmarkStatusViewModel by viewModels()
private val day by lazy<Day>(LazyThreadSafetyMode.NONE) {
intent.getParcelableExtra(EXTRA_DAY)!!
}
private val track by lazy<Track>(LazyThreadSafetyMode.NONE) {
intent.getParcelableExtra(EXTRA_TRACK)!!
}
private var isTabletLandscape = false
private var lastSelectedEvent: Event? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.track_schedule)
val toolbar: Toolbar = findViewById(R.id.toolbar)
setSupportActionBar(toolbar)
supportActionBar?.run {
setDisplayHomeAsUpEnabled(true)
title = track.toString()
subtitle = day.toString()
}
title = "$track, $day"
val trackType = track.type
if (isLightTheme) {
window.statusBarColorCompat = ContextCompat.getColor(this, trackType.statusBarColorResId)
val trackAppBarColor = ContextCompat.getColorStateList(this, trackType.appBarColorResId)!!
setTaskColorPrimary(trackAppBarColor.defaultColor)
toolbar.tintBackground(trackAppBarColor)
} else {
val trackTextColor = ContextCompat.getColorStateList(this, trackType.textColorResId)!!
toolbar.setTitleTextColor(trackTextColor)
}
isTabletLandscape = resources.getBoolean(R.bool.tablet_landscape)
val fm = supportFragmentManager
if (savedInstanceState == null) {
val fromEventId = intent.getLongExtra(EXTRA_FROM_EVENT_ID, -1L)
val trackScheduleListFragment = if (fromEventId != -1L) {
TrackScheduleListFragment.newInstance(day, track, fromEventId)
} else {
TrackScheduleListFragment.newInstance(day, track)
}
fm.commit { add(R.id.schedule, trackScheduleListFragment) }
} else {
// Cleanup after switching from dual pane to single pane mode
if (!isTabletLandscape) {
val eventDetailsFragment = fm.findFragmentById(R.id.event)
val roomImageDialogFragment = fm.findFragmentByTag(RoomImageDialogFragment.TAG)
if (eventDetailsFragment != null || roomImageDialogFragment != null) {
fm.commit {
if (eventDetailsFragment != null) {
remove(eventDetailsFragment)
}
if (roomImageDialogFragment != null) {
remove(roomImageDialogFragment)
}
}
}
}
}
if (isTabletLandscape) {
findViewById<ImageButton?>(R.id.fab)?.setupBookmarkStatus(bookmarkStatusViewModel, this)
// Enable Android Beam
setNfcAppDataPushMessageCallbackIfAvailable(this)
}
}
override fun getSupportParentActivityIntent(): Intent? {
return super.getSupportParentActivityIntent()?.apply {
// Add FLAG_ACTIVITY_SINGLE_TOP to ensure the Main activity in the back stack is not re-created
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
}
}
// TrackScheduleListFragment.Callbacks
override fun onEventSelected(position: Int, event: Event?) {
if (isTabletLandscape) {
// Tablet mode: Show event details in the right pane fragment
lastSelectedEvent = event
val fm = supportFragmentManager
val currentFragment = fm.findFragmentById(R.id.event) as EventDetailsFragment?
if (event != null) {
// Only replace the fragment if the event is different
if (currentFragment?.event != event) {
// Allow state loss since the event fragment will be synchronized with the list selection after activity re-creation
fm.commit(allowStateLoss = true) {
setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE)
replace(R.id.event, EventDetailsFragment.newInstance(event))
}
}
} else {
// Nothing is selected because the list is empty
if (currentFragment != null) {
fm.commit(allowStateLoss = true) { remove(currentFragment) }
}
}
bookmarkStatusViewModel.event = event
} else {
// Classic mode: Show event details in a new activity
val intent = Intent(this, TrackScheduleEventActivity::class.java)
.putExtra(TrackScheduleEventActivity.EXTRA_DAY, day)
.putExtra(TrackScheduleEventActivity.EXTRA_TRACK, track)
.putExtra(TrackScheduleEventActivity.EXTRA_POSITION, position)
startActivity(intent)
}
}
// CreateNfcAppDataCallback
override fun createNfcAppData(): NdefRecord? {
return lastSelectedEvent?.toNfcAppData(this)
}
companion object {
const val EXTRA_DAY = "day"
const val EXTRA_TRACK = "track"
// Optional extra used as a hint for up navigation from an event
const val EXTRA_FROM_EVENT_ID = "from_event_id"
}
}

View file

@ -1,226 +0,0 @@
package be.digitalia.fosdem.activities;
import android.content.Intent;
import android.content.res.ColorStateList;
import android.nfc.NdefRecord;
import android.os.Bundle;
import android.view.View;
import android.widget.ImageButton;
import com.google.android.material.appbar.AppBarLayout;
import com.google.android.material.bottomappbar.BottomAppBar;
import java.util.List;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.widget.Toolbar;
import androidx.core.content.ContextCompat;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentActivity;
import androidx.lifecycle.Observer;
import androidx.lifecycle.ViewModelProvider;
import androidx.viewpager2.adapter.FragmentStateAdapter;
import androidx.viewpager2.widget.ViewPager2;
import be.digitalia.fosdem.R;
import be.digitalia.fosdem.fragments.EventDetailsFragment;
import be.digitalia.fosdem.model.Day;
import be.digitalia.fosdem.model.Event;
import be.digitalia.fosdem.model.Track;
import be.digitalia.fosdem.utils.NfcUtils;
import be.digitalia.fosdem.utils.NfcUtils.CreateNfcAppDataCallback;
import be.digitalia.fosdem.utils.RecyclerViewUtils;
import be.digitalia.fosdem.utils.ThemeUtils;
import be.digitalia.fosdem.viewmodels.BookmarkStatusViewModel;
import be.digitalia.fosdem.viewmodels.TrackScheduleEventViewModel;
import be.digitalia.fosdem.widgets.BookmarkStatusAdapter;
import be.digitalia.fosdem.widgets.ContentLoadingProgressBar;
/**
* Event view of the track schedule; allows to slide between events of the same track using a ViewPager.
*
* @author Christophe Beyls
*/
public class TrackScheduleEventActivity extends AppCompatActivity implements Observer<List<Event>>, CreateNfcAppDataCallback {
public static final String EXTRA_DAY = "day";
public static final String EXTRA_TRACK = "track";
public static final String EXTRA_POSITION = "position";
private int initialPosition = -1;
private ContentLoadingProgressBar progress;
private ViewPager2 pager;
TrackScheduleEventAdapter adapter;
BookmarkStatusViewModel bookmarkStatusViewModel;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.track_schedule_event);
AppBarLayout appBarLayout = findViewById(R.id.appbar);
Toolbar toolbar = findViewById(R.id.toolbar);
BottomAppBar bottomAppBar = findViewById(R.id.bottom_appbar);
setSupportActionBar(bottomAppBar);
Bundle extras = getIntent().getExtras();
final Day day = extras.getParcelable(EXTRA_DAY);
final Track track = extras.getParcelable(EXTRA_TRACK);
progress = findViewById(R.id.progress);
pager = findViewById(R.id.pager);
RecyclerViewUtils.enforceSingleScrollDirection(RecyclerViewUtils.getRecyclerView(pager));
adapter = new TrackScheduleEventAdapter(this);
if (savedInstanceState == null) {
initialPosition = extras.getInt(EXTRA_POSITION, -1);
}
toolbar.setNavigationIcon(R.drawable.abc_ic_ab_back_material);
toolbar.setNavigationContentDescription(R.string.abc_action_bar_up_description);
toolbar.setNavigationOnClickListener(v -> onSupportNavigateUp());
toolbar.setTitle(track.toString());
toolbar.setSubtitle(day.toString());
setTitle(String.format("%1$s, %2$s", track.toString(), day.toString()));
final Track.Type trackType = track.getType();
if (ThemeUtils.isLightTheme(this)) {
final ColorStateList trackAppBarColor = ContextCompat.getColorStateList(this, trackType.getAppBarColorResId());
final int trackStatusBarColor = ContextCompat.getColor(this, trackType.getStatusBarColorResId());
ThemeUtils.setActivityColors(this, trackAppBarColor.getDefaultColor(), trackStatusBarColor);
ThemeUtils.tintBackground(appBarLayout, trackAppBarColor);
} else {
final ColorStateList trackTextColor = ContextCompat.getColorStateList(this, trackType.getTextColorResId());
toolbar.setTitleTextColor(trackTextColor);
}
final ViewModelProvider viewModelProvider = new ViewModelProvider(this);
// Monitor the currently displayed event to update the bookmark status in FAB
ImageButton floatingActionButton = findViewById(R.id.fab);
bookmarkStatusViewModel = viewModelProvider.get(BookmarkStatusViewModel.class);
BookmarkStatusAdapter.setupWithImageButton(bookmarkStatusViewModel, this, floatingActionButton);
pager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() {
@Override
public void onPageSelected(int position) {
bookmarkStatusViewModel.setEvent(adapter.getEvent(position));
}
});
setCustomProgressVisibility(true);
final TrackScheduleEventViewModel viewModel = viewModelProvider.get(TrackScheduleEventViewModel.class);
viewModel.setTrack(day, track);
viewModel.getScheduleSnapshot().observe(this, this);
// Enable Android Beam
NfcUtils.setAppDataPushMessageCallbackIfAvailable(this, this);
}
private void setCustomProgressVisibility(boolean isVisible) {
if (isVisible) {
progress.show();
} else {
progress.hide();
}
}
@Nullable
@Override
public Intent getSupportParentActivityIntent() {
final Event event = bookmarkStatusViewModel.getEvent();
if (event == null) {
return null;
}
// Navigate up to the track associated with this event
return new Intent(this, TrackScheduleActivity.class)
.putExtra(TrackScheduleActivity.EXTRA_DAY, event.getDay())
.putExtra(TrackScheduleActivity.EXTRA_TRACK, event.getTrack())
.putExtra(TrackScheduleActivity.EXTRA_FROM_EVENT_ID, event.getId());
}
@Override
public NdefRecord createNfcAppData() {
final Event event = bookmarkStatusViewModel.getEvent();
if (event == null) {
return null;
}
return NfcUtils.createEventAppData(this, event);
}
@Override
public void onChanged(List<Event> schedule) {
setCustomProgressVisibility(false);
if (schedule != null) {
pager.setVisibility(View.VISIBLE);
adapter.setSchedule(schedule);
// Delay setting the adapter
// to ensure the current position is restored properly
if (pager.getAdapter() == null) {
pager.setAdapter(adapter);
if (initialPosition != -1) {
pager.setCurrentItem(initialPosition, false);
initialPosition = -1;
}
final int currentPosition = pager.getCurrentItem();
if (currentPosition >= 0) {
bookmarkStatusViewModel.setEvent(adapter.getEvent(currentPosition));
}
}
}
}
private static class TrackScheduleEventAdapter extends FragmentStateAdapter {
private List<Event> events = null;
TrackScheduleEventAdapter(@NonNull FragmentActivity fragmentActivity) {
super(fragmentActivity);
}
public void setSchedule(List<Event> schedule) {
this.events = schedule;
notifyDataSetChanged();
}
@Override
public int getItemCount() {
return (events == null) ? 0 : events.size();
}
@Override
public long getItemId(int position) {
return events.get(position).getId();
}
@Override
public boolean containsItem(long itemId) {
final int count = getItemCount();
for (int i = 0; i < count; ++i) {
if (events.get(i).getId() == itemId) {
return true;
}
}
return false;
}
@NonNull
@Override
public Fragment createFragment(int position) {
final Fragment f = EventDetailsFragment.newInstance(events.get(position));
// Workaround for duplicate menu items bug
f.setMenuVisibility(false);
return f;
}
public Event getEvent(int position) {
if (position < 0 || position >= getItemCount()) {
return null;
}
return events.get(position);
}
}
}

View file

@ -0,0 +1,162 @@
package be.digitalia.fosdem.activities
import android.content.Intent
import android.nfc.NdefRecord
import android.os.Bundle
import android.view.View
import android.widget.ImageButton
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.Toolbar
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.observe
import androidx.viewpager2.adapter.FragmentStateAdapter
import androidx.viewpager2.widget.ViewPager2
import be.digitalia.fosdem.R
import be.digitalia.fosdem.fragments.EventDetailsFragment
import be.digitalia.fosdem.model.Day
import be.digitalia.fosdem.model.Event
import be.digitalia.fosdem.model.Track
import be.digitalia.fosdem.utils.*
import be.digitalia.fosdem.viewmodels.BookmarkStatusViewModel
import be.digitalia.fosdem.viewmodels.TrackScheduleEventViewModel
import be.digitalia.fosdem.widgets.ContentLoadingProgressBar
import be.digitalia.fosdem.widgets.setupBookmarkStatus
/**
* Event view of the track schedule; allows to slide between events of the same track using a ViewPager.
*
* @author Christophe Beyls
*/
class TrackScheduleEventActivity : AppCompatActivity(), CreateNfcAppDataCallback {
private val bookmarkStatusViewModel: BookmarkStatusViewModel by viewModels()
private val viewModel: TrackScheduleEventViewModel by viewModels()
private var initialPosition = -1
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.track_schedule_event)
setSupportActionBar(findViewById(R.id.bottom_appbar))
val intent = intent
val day: Day = intent.getParcelableExtra(EXTRA_DAY)!!
val track: Track = intent.getParcelableExtra(EXTRA_TRACK)!!
val progress: ContentLoadingProgressBar = findViewById(R.id.progress)
val pager: ViewPager2 = findViewById(R.id.pager)
pager.recyclerView.enforceSingleScrollDirection()
val adapter = TrackScheduleEventAdapter(this)
if (savedInstanceState == null) {
initialPosition = intent.getIntExtra(EXTRA_POSITION, -1)
}
val toolbar = findViewById<Toolbar>(R.id.toolbar).apply {
setNavigationIcon(R.drawable.abc_ic_ab_back_material)
setNavigationContentDescription(R.string.abc_action_bar_up_description)
setNavigationOnClickListener { onSupportNavigateUp() }
title = track.toString()
subtitle = day.toString()
}
title = "$track, $day"
val trackType = track.type
if (isLightTheme) {
window.statusBarColorCompat = ContextCompat.getColor(this, trackType.statusBarColorResId)
val trackAppBarColor = ContextCompat.getColorStateList(this, trackType.appBarColorResId)!!
setTaskColorPrimary(trackAppBarColor.defaultColor)
findViewById<View>(R.id.appbar).tintBackground(trackAppBarColor)
} else {
val trackTextColor = ContextCompat.getColorStateList(this, trackType.textColorResId)
toolbar.setTitleTextColor(trackTextColor!!)
}
// Monitor the currently displayed event to update the bookmark status in FAB
findViewById<ImageButton>(R.id.fab).setupBookmarkStatus(bookmarkStatusViewModel, this)
pager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
override fun onPageSelected(position: Int) {
bookmarkStatusViewModel.event = adapter.getEvent(position)
}
})
progress.show()
with(viewModel) {
setDayAndTrack(day, track)
scheduleSnapshot.observe(this@TrackScheduleEventActivity) { events ->
progress.hide()
pager.isVisible = true
adapter.events = events
// Delay setting the adapter
// to ensure the current position is restored properly
if (pager.adapter == null) {
pager.adapter = adapter
if (initialPosition != -1) {
pager.setCurrentItem(initialPosition, false)
initialPosition = -1
}
bookmarkStatusViewModel.event = adapter.getEvent(pager.currentItem)
}
}
}
// Enable Android Beam
setNfcAppDataPushMessageCallbackIfAvailable(this)
}
override fun getSupportParentActivityIntent(): Intent? {
val event = bookmarkStatusViewModel.event ?: return null
// Navigate up to the track associated with this event
return Intent(this, TrackScheduleActivity::class.java)
.putExtra(TrackScheduleActivity.EXTRA_DAY, event.day)
.putExtra(TrackScheduleActivity.EXTRA_TRACK, event.track)
.putExtra(TrackScheduleActivity.EXTRA_FROM_EVENT_ID, event.id)
}
override fun createNfcAppData(): NdefRecord? {
return bookmarkStatusViewModel.event?.toNfcAppData(this)
}
class TrackScheduleEventAdapter(fragmentActivity: FragmentActivity) : FragmentStateAdapter(fragmentActivity) {
var events: List<Event>? = null
set(value) {
field = value
notifyDataSetChanged()
}
override fun getItemCount() = events?.size ?: 0
override fun getItemId(position: Int) = events!![position].id
override fun containsItem(itemId: Long): Boolean {
return events?.any { it.id == itemId } ?: false
}
override fun createFragment(position: Int): Fragment {
return EventDetailsFragment.newInstance(events!![position]).apply {
// Workaround for duplicate menu items bug
setMenuVisibility(false)
}
}
fun getEvent(position: Int): Event? {
return if (position !in 0 until itemCount) {
null
} else events!![position]
}
}
companion object {
const val EXTRA_DAY = "day"
const val EXTRA_TRACK = "track"
const val EXTRA_POSITION = "position"
}
}

View file

@ -1,318 +0,0 @@
package be.digitalia.fosdem.adapters;
import android.content.Context;
import android.content.Intent;
import android.content.res.TypedArray;
import android.graphics.Typeface;
import android.text.SpannableString;
import android.text.Spanned;
import android.text.TextUtils;
import android.text.style.ForegroundColorSpan;
import android.text.style.StyleSpan;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import androidx.annotation.ColorInt;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import androidx.collection.SimpleArrayMap;
import androidx.core.content.ContextCompat;
import androidx.core.util.ObjectsCompat;
import androidx.lifecycle.Observer;
import androidx.recyclerview.widget.DiffUtil;
import androidx.recyclerview.widget.ListAdapter;
import androidx.recyclerview.widget.RecyclerView;
import androidx.savedstate.SavedStateRegistryOwner;
import java.text.DateFormat;
import java.util.Date;
import java.util.List;
import java.util.Map;
import be.digitalia.fosdem.R;
import be.digitalia.fosdem.activities.EventDetailsActivity;
import be.digitalia.fosdem.api.FosdemApi;
import be.digitalia.fosdem.model.Event;
import be.digitalia.fosdem.model.RoomStatus;
import be.digitalia.fosdem.model.Track;
import be.digitalia.fosdem.utils.DateUtils;
import be.digitalia.fosdem.widgets.MultiChoiceHelper;
public class BookmarksAdapter extends ListAdapter<Event, BookmarksAdapter.ViewHolder>
implements Observer<Map<String, RoomStatus>> {
private static final DiffUtil.ItemCallback<Event> DIFF_CALLBACK = new SimpleItemCallback<Event>() {
@Override
public boolean areContentsTheSame(@NonNull Event oldEvent, @NonNull Event newEvent) {
return ObjectsCompat.equals(oldEvent.getTitle(), newEvent.getTitle())
&& ObjectsCompat.equals(oldEvent.getPersonsSummary(), newEvent.getPersonsSummary())
&& ObjectsCompat.equals(oldEvent.getTrack(), newEvent.getTrack())
&& ObjectsCompat.equals(oldEvent.getDay(), newEvent.getDay())
&& ObjectsCompat.equals(oldEvent.getStartTime(), newEvent.getStartTime())
&& ObjectsCompat.equals(oldEvent.getEndTime(), newEvent.getEndTime())
&& ObjectsCompat.equals(oldEvent.getRoomName(), newEvent.getRoomName());
}
};
static final Object DETAILS_PAYLOAD = new Object();
private final DateFormat timeDateFormat;
@ColorInt
private final int errorColor;
private final SimpleArrayMap<RecyclerView.AdapterDataObserver, BookmarksDataObserverWrapper> observers = new SimpleArrayMap<>();
final MultiChoiceHelper multiChoiceHelper;
private Map<String, RoomStatus> roomStatuses;
public BookmarksAdapter(@NonNull AppCompatActivity activity, @NonNull SavedStateRegistryOwner owner,
@NonNull MultiChoiceHelper.MultiChoiceModeListener multiChoiceModeListener) {
super(DIFF_CALLBACK);
setHasStableIds(true);
timeDateFormat = DateUtils.getTimeDateFormat(activity);
TypedArray a = activity.getTheme().obtainStyledAttributes(R.styleable.ErrorColors);
errorColor = a.getColor(R.styleable.ErrorColors_colorError, 0);
a.recycle();
multiChoiceHelper = new MultiChoiceHelper(activity, owner, this);
multiChoiceHelper.setMultiChoiceModeListener(multiChoiceModeListener);
FosdemApi.getRoomStatuses(activity).observe(owner, this);
}
@NonNull
public MultiChoiceHelper getMultiChoiceHelper() {
return multiChoiceHelper;
}
@Override
public void onChanged(@Nullable Map<String, RoomStatus> roomStatuses) {
this.roomStatuses = roomStatuses;
notifyItemRangeChanged(0, getItemCount(), DETAILS_PAYLOAD);
}
@Override
public long getItemId(int position) {
return getItem(position).getId();
}
@NonNull
@Override
public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_event, parent, false);
return new ViewHolder(view, multiChoiceHelper, timeDateFormat, errorColor);
}
private RoomStatus getRoomStatus(Event event) {
return (roomStatuses == null) ? null : roomStatuses.get(event.getRoomName());
}
@Override
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
final Event event = getItem(position);
holder.bind(event);
final Event previous = position > 0 ? getItem(position - 1) : null;
final Event next = position + 1 < getItemCount() ? getItem(position + 1) : null;
holder.bindDetails(event, previous, next, getRoomStatus(event));
holder.bindSelection();
}
@Override
public void onBindViewHolder(@NonNull ViewHolder holder, int position, @NonNull List<Object> payloads) {
if (payloads.isEmpty()) {
onBindViewHolder(holder, position);
} else {
final Event event = getItem(position);
if (payloads.contains(DETAILS_PAYLOAD)) {
final Event previous = position > 0 ? getItem(position - 1) : null;
final Event next = position + 1 < getItemCount() ? getItem(position + 1) : null;
holder.bindDetails(event, previous, next, getRoomStatus(event));
}
if (payloads.contains(MultiChoiceHelper.SELECTION_PAYLOAD)) {
holder.bindSelection();
}
}
}
@Override
public void registerAdapterDataObserver(@NonNull RecyclerView.AdapterDataObserver observer) {
if (!observers.containsKey(observer)) {
final BookmarksDataObserverWrapper wrapper = new BookmarksDataObserverWrapper(observer, this);
observers.put(observer, wrapper);
super.registerAdapterDataObserver(wrapper);
}
}
@Override
public void unregisterAdapterDataObserver(@NonNull RecyclerView.AdapterDataObserver observer) {
final BookmarksDataObserverWrapper wrapper = observers.remove(observer);
if (wrapper != null) {
super.unregisterAdapterDataObserver(wrapper);
}
}
static class ViewHolder extends MultiChoiceHelper.ViewHolder implements View.OnClickListener {
final TextView title;
final TextView persons;
final TextView trackName;
final TextView details;
private final DateFormat timeDateFormat;
@ColorInt
private final int errorColor;
Event event;
public ViewHolder(@NonNull View itemView, @NonNull MultiChoiceHelper helper,
@NonNull DateFormat timeDateFormat, @ColorInt int errorColor) {
super(itemView, helper);
title = itemView.findViewById(R.id.title);
persons = itemView.findViewById(R.id.persons);
trackName = itemView.findViewById(R.id.track_name);
details = itemView.findViewById(R.id.details);
setOnClickListener(this);
this.timeDateFormat = timeDateFormat;
this.errorColor = errorColor;
}
void bind(@NonNull Event event) {
Context context = itemView.getContext();
this.event = event;
title.setText(event.getTitle());
String personsSummary = event.getPersonsSummary();
persons.setText(personsSummary);
persons.setVisibility(TextUtils.isEmpty(personsSummary) ? View.GONE : View.VISIBLE);
Track track = event.getTrack();
trackName.setText(track.getName());
trackName.setTextColor(ContextCompat.getColorStateList(context, track.getType().getTextColorResId()));
trackName.setContentDescription(context.getString(R.string.track_content_description, track.getName()));
}
void bindDetails(@NonNull Event event, @Nullable Event previous, @Nullable Event next, @Nullable RoomStatus roomStatus) {
Context context = details.getContext();
Date startTime = event.getStartTime();
Date endTime = event.getEndTime();
String startTimeString = (startTime != null) ? timeDateFormat.format(startTime) : "?";
String endTimeString = (endTime != null) ? timeDateFormat.format(endTime) : "?";
String roomName = event.getRoomName();
String detailsText = String.format("%1$s, %2$s ― %3$s | %4$s", event.getDay().getShortName(), startTimeString, endTimeString, roomName);
SpannableString detailsSpannable = new SpannableString(detailsText);
CharSequence detailsDescription = detailsText;
// Highlight the date and time with error color in case of conflicting schedules
if (isOverlapping(event, previous, next)) {
int endPosition = detailsText.indexOf(" | ");
detailsSpannable.setSpan(new ForegroundColorSpan(errorColor), 0, endPosition, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
detailsSpannable.setSpan(new StyleSpan(Typeface.BOLD), 0, endPosition, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
detailsDescription = context.getString(R.string.bookmark_conflict_content_description, detailsDescription);
}
if (roomStatus != null) {
int color = ContextCompat.getColor(context, roomStatus.getColorResId());
detailsSpannable.setSpan(new ForegroundColorSpan(color),
detailsText.length() - roomName.length(),
detailsText.length(),
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
details.setText(detailsSpannable);
details.setContentDescription(context.getString(R.string.details_content_description, detailsDescription));
}
/**
* Checks if the current event is overlapping with the previous or next one.
*/
private static boolean isOverlapping(@NonNull Event event, @Nullable Event previous, @Nullable Event next) {
final Date startTime = event.getStartTime();
final Date previousEndTime = (previous == null) ? null : previous.getEndTime();
if (startTime != null && previousEndTime != null && previousEndTime.getTime() > startTime.getTime()) {
// The event overlaps with the previous one
return true;
}
final Date endTime = event.getEndTime();
final Date nextStartTime = (next == null) ? null : next.getStartTime();
// The event overlaps with the next one
return endTime != null && nextStartTime != null && nextStartTime.getTime() < endTime.getTime();
}
@Override
public void onClick(View view) {
if (event != null) {
Context context = view.getContext();
Intent intent = new Intent(context, EventDetailsActivity.class)
.putExtra(EventDetailsActivity.EXTRA_EVENT, event);
context.startActivity(intent);
}
}
}
/**
* An observer dispatching updates to the source observer while additionally notifying changes
* of the immediately previous and next items in order to properly update their overlapping status display.
*/
static class BookmarksDataObserverWrapper extends RecyclerView.AdapterDataObserver {
private final RecyclerView.AdapterDataObserver observer;
private final RecyclerView.Adapter<?> adapter;
public BookmarksDataObserverWrapper(RecyclerView.AdapterDataObserver observer, RecyclerView.Adapter<?> adapter) {
this.observer = observer;
this.adapter = adapter;
}
private void updatePrevious(int position) {
if (position >= 0) {
observer.onItemRangeChanged(position, 1, DETAILS_PAYLOAD);
}
}
private void updateNext(int position) {
if (position < adapter.getItemCount()) {
observer.onItemRangeChanged(position, 1, DETAILS_PAYLOAD);
}
}
@Override
public void onChanged() {
observer.onChanged();
}
@Override
public void onItemRangeChanged(int positionStart, int itemCount) {
observer.onItemRangeChanged(positionStart, itemCount);
updatePrevious(positionStart - 1);
updateNext(positionStart + itemCount);
}
@Override
public void onItemRangeChanged(int positionStart, int itemCount, @Nullable Object payload) {
observer.onItemRangeChanged(positionStart, itemCount, payload);
updatePrevious(positionStart - 1);
updateNext(positionStart + itemCount);
}
@Override
public void onItemRangeInserted(int positionStart, int itemCount) {
observer.onItemRangeInserted(positionStart, itemCount);
updatePrevious(positionStart - 1);
updateNext(positionStart + itemCount);
}
@Override
public void onItemRangeRemoved(int positionStart, int itemCount) {
observer.onItemRangeRemoved(positionStart, itemCount);
updatePrevious(positionStart - 1);
updateNext(positionStart);
}
@Override
public void onItemRangeMoved(int fromPosition, int toPosition, int itemCount) {
updatePrevious(fromPosition - 1);
updateNext(fromPosition + itemCount);
observer.onItemRangeMoved(fromPosition, toPosition, itemCount);
updatePrevious(toPosition - 1);
updateNext(toPosition + itemCount);
}
}
}

View file

@ -0,0 +1,251 @@
package be.digitalia.fosdem.adapters
import android.content.Intent
import android.graphics.Typeface
import android.text.SpannableString
import android.text.style.ForegroundColorSpan
import android.text.style.StyleSpan
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.annotation.ColorInt
import androidx.appcompat.app.AppCompatActivity
import androidx.collection.SimpleArrayMap
import androidx.core.content.ContextCompat
import androidx.core.text.set
import androidx.core.view.isGone
import androidx.lifecycle.observe
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.AdapterDataObserver
import androidx.savedstate.SavedStateRegistryOwner
import be.digitalia.fosdem.R
import be.digitalia.fosdem.activities.EventDetailsActivity
import be.digitalia.fosdem.api.FosdemApi
import be.digitalia.fosdem.model.Event
import be.digitalia.fosdem.model.RoomStatus
import be.digitalia.fosdem.utils.DateUtils
import be.digitalia.fosdem.widgets.MultiChoiceHelper
import java.text.DateFormat
class BookmarksAdapter(activity: AppCompatActivity, owner: SavedStateRegistryOwner,
multiChoiceModeListener: MultiChoiceHelper.MultiChoiceModeListener? = null)
: ListAdapter<Event, BookmarksAdapter.ViewHolder>(DIFF_CALLBACK) {
private val timeDateFormat = DateUtils.getTimeDateFormat(activity)
@ColorInt
private val errorColor: Int
private val observers = SimpleArrayMap<AdapterDataObserver, BookmarksDataObserverWrapper>()
private var roomStatuses: Map<String, RoomStatus>? = null
init {
setHasStableIds(true)
with(activity.theme.obtainStyledAttributes(R.styleable.ErrorColors)) {
errorColor = getColor(R.styleable.ErrorColors_colorError, 0)
recycle()
}
FosdemApi.getRoomStatuses(activity).observe(owner) { statuses ->
roomStatuses = statuses
notifyItemRangeChanged(0, itemCount, DETAILS_PAYLOAD)
}
}
val multiChoiceHelper = MultiChoiceHelper(activity, owner, this).apply {
setMultiChoiceModeListener(multiChoiceModeListener)
}
override fun getItemId(position: Int) = getItem(position).id
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(parent.context).inflate(R.layout.item_event, parent, false)
return ViewHolder(view, multiChoiceHelper, timeDateFormat, errorColor)
}
private fun getRoomStatus(event: Event): RoomStatus? {
return roomStatuses?.let { it[event.roomName] }
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val event = getItem(position)
holder.bind(event)
val previous = if (position > 0) getItem(position - 1) else null
val next = if (position + 1 < itemCount) getItem(position + 1) else null
holder.bindDetails(event, previous, next, getRoomStatus(event))
holder.bindSelection()
}
override fun onBindViewHolder(holder: ViewHolder, position: Int, payloads: List<Any>) {
if (payloads.isEmpty()) {
onBindViewHolder(holder, position)
} else {
val event = getItem(position)
if (DETAILS_PAYLOAD in payloads) {
val previous = if (position > 0) getItem(position - 1) else null
val next = if (position + 1 < itemCount) getItem(position + 1) else null
holder.bindDetails(event, previous, next, getRoomStatus(event))
}
if (MultiChoiceHelper.SELECTION_PAYLOAD in payloads) {
holder.bindSelection()
}
}
}
override fun registerAdapterDataObserver(observer: AdapterDataObserver) {
if (!observers.containsKey(observer)) {
val wrapper = BookmarksDataObserverWrapper(observer, this)
observers.put(observer, wrapper)
super.registerAdapterDataObserver(wrapper)
}
}
override fun unregisterAdapterDataObserver(observer: AdapterDataObserver) {
val wrapper = observers.remove(observer)
if (wrapper != null) {
super.unregisterAdapterDataObserver(wrapper)
}
}
class ViewHolder(itemView: View, helper: MultiChoiceHelper,
private val timeDateFormat: DateFormat, @ColorInt private val errorColor: Int)
: MultiChoiceHelper.ViewHolder(itemView, helper), View.OnClickListener {
private val title: TextView = itemView.findViewById(R.id.title)
private val persons: TextView = itemView.findViewById(R.id.persons)
private val trackName: TextView = itemView.findViewById(R.id.track_name)
private val details: TextView = itemView.findViewById(R.id.details)
private var event: Event? = null
init {
setOnClickListener(this)
}
fun bind(event: Event) {
val context = itemView.context
this.event = event
title.text = event.title
val personsSummary = event.personsSummary
persons.text = personsSummary
persons.isGone = personsSummary.isNullOrEmpty()
val track = event.track
trackName.text = track.name
trackName.setTextColor(ContextCompat.getColorStateList(context, track.type.textColorResId))
trackName.contentDescription = context.getString(R.string.track_content_description, track.name)
}
fun bindDetails(event: Event, previous: Event?, next: Event?, roomStatus: RoomStatus?) {
val context = details.context
val startTime = event.startTime
val endTime = event.endTime
val startTimeString = if (startTime != null) timeDateFormat.format(startTime) else "?"
val endTimeString = if (endTime != null) timeDateFormat.format(endTime) else "?"
val roomName = event.roomName ?: ""
val detailsText: CharSequence = "${event.day.shortName}, $startTimeString$endTimeString | $roomName"
val detailsSpannable = SpannableString(detailsText)
var detailsDescription = detailsText
// Highlight the date and time with error color in case of conflicting schedules
if (isOverlapping(event, previous, next)) {
val endPosition = detailsText.indexOf(" | ")
detailsSpannable[0, endPosition] = ForegroundColorSpan(errorColor)
detailsSpannable[0, endPosition] = StyleSpan(Typeface.BOLD)
detailsDescription = context.getString(R.string.bookmark_conflict_content_description, detailsDescription)
}
if (roomStatus != null) {
val color = ContextCompat.getColor(context, roomStatus.colorResId)
detailsSpannable[detailsText.length - roomName.length, detailsText.length] = ForegroundColorSpan(color)
}
details.text = detailsSpannable
details.contentDescription = context.getString(R.string.details_content_description, detailsDescription)
}
/**
* Checks if the current event is overlapping with the previous or next one.
*/
private fun isOverlapping(event: Event, previous: Event?, next: Event?): Boolean {
val startTime = event.startTime
val previousEndTime = previous?.endTime
if (startTime != null && previousEndTime != null && previousEndTime > startTime) {
// The event overlaps with the previous one
return true
}
val endTime = event.endTime
val nextStartTime = next?.startTime
// The event overlaps with the next one
return endTime != null && nextStartTime != null && nextStartTime < endTime
}
override fun onClick(view: View) {
event?.let {
val context = view.context
val intent = Intent(context, EventDetailsActivity::class.java)
.putExtra(EventDetailsActivity.EXTRA_EVENT, it)
context.startActivity(intent)
}
}
}
/**
* An observer dispatching updates to the source observer while additionally notifying changes
* of the immediately previous and next items in order to properly update their overlapping status display.
*/
private class BookmarksDataObserverWrapper(private val observer: AdapterDataObserver, private val adapter: RecyclerView.Adapter<*>)
: AdapterDataObserver() {
private fun updatePrevious(position: Int) {
if (position >= 0) {
observer.onItemRangeChanged(position, 1, DETAILS_PAYLOAD)
}
}
private fun updateNext(position: Int) {
if (position < adapter.itemCount) {
observer.onItemRangeChanged(position, 1, DETAILS_PAYLOAD)
}
}
override fun onChanged() {
observer.onChanged()
}
override fun onItemRangeChanged(positionStart: Int, itemCount: Int) {
observer.onItemRangeChanged(positionStart, itemCount)
updatePrevious(positionStart - 1)
updateNext(positionStart + itemCount)
}
override fun onItemRangeChanged(positionStart: Int, itemCount: Int, payload: Any?) {
observer.onItemRangeChanged(positionStart, itemCount, payload)
updatePrevious(positionStart - 1)
updateNext(positionStart + itemCount)
}
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
observer.onItemRangeInserted(positionStart, itemCount)
updatePrevious(positionStart - 1)
updateNext(positionStart + itemCount)
}
override fun onItemRangeRemoved(positionStart: Int, itemCount: Int) {
observer.onItemRangeRemoved(positionStart, itemCount)
updatePrevious(positionStart - 1)
updateNext(positionStart)
}
override fun onItemRangeMoved(fromPosition: Int, toPosition: Int, itemCount: Int) {
updatePrevious(fromPosition - 1)
updateNext(fromPosition + itemCount)
observer.onItemRangeMoved(fromPosition, toPosition, itemCount)
updatePrevious(toPosition - 1)
updateNext(toPosition + itemCount)
}
}
companion object {
private val DIFF_CALLBACK = createSimpleItemCallback<Event> { oldItem, newItem ->
oldItem.id == newItem.id
}
private val DETAILS_PAYLOAD = Any()
}
}

View file

@ -1,218 +0,0 @@
package be.digitalia.fosdem.adapters;
import android.util.SparseArray;
import android.view.ViewGroup;
import java.util.Arrays;
import java.util.List;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
/**
* Adapter which concatenates the items of multiple adapters.
* Doesn't support stable ids, but properly delegates changes notifications.
* <p>
* Adapters may provide multiple view types but they must not overlap.
* It's recommended to always use the item layout id as view type.
*
* @author Christophe Beyls
*/
public class ConcatAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> {
private final RecyclerView.Adapter<RecyclerView.ViewHolder>[] adapters;
private final RecyclerView.AdapterDataObserver[] adapterObservers;
final int[] offsets;
int totalItemCount = -1;
private final SparseArray<RecyclerView.Adapter<RecyclerView.ViewHolder>> viewTypeAdapters = new SparseArray<>();
private class InternalObserver extends RecyclerView.AdapterDataObserver {
private final int adapterIndex;
InternalObserver(int adapterIndex) {
this.adapterIndex = adapterIndex;
}
@Override
public void onChanged() {
totalItemCount = -1;
notifyDataSetChanged();
}
@Override
public void onItemRangeChanged(int positionStart, int itemCount) {
if (totalItemCount != -1) {
notifyItemRangeChanged(positionStart + offsets[adapterIndex], itemCount);
}
}
@Override
public void onItemRangeChanged(int positionStart, int itemCount, Object payload) {
if (totalItemCount != -1) {
notifyItemRangeChanged(positionStart + offsets[adapterIndex], itemCount, payload);
}
}
@Override
public void onItemRangeInserted(int positionStart, int itemCount) {
if (totalItemCount != -1) {
for (int i = adapterIndex + 1, size = offsets.length; i < size; ++i) {
offsets[i] += itemCount;
}
totalItemCount += itemCount;
notifyItemRangeInserted(positionStart + offsets[adapterIndex], itemCount);
}
}
@Override
public void onItemRangeRemoved(int positionStart, int itemCount) {
if (totalItemCount != -1) {
for (int i = adapterIndex + 1, size = offsets.length; i < size; ++i) {
offsets[i] -= itemCount;
}
totalItemCount -= itemCount;
notifyItemRangeRemoved(positionStart + offsets[adapterIndex], itemCount);
}
}
@Override
public void onItemRangeMoved(int fromPosition, int toPosition, int itemCount) {
if (totalItemCount != -1) {
final int offset = offsets[adapterIndex];
if (itemCount == 1) {
notifyItemMoved(fromPosition + offset, toPosition + offset);
} else if (fromPosition < toPosition) {
for (int i = itemCount - 1; i >= 0; --i) {
notifyItemMoved(fromPosition + i + offset, toPosition + i + offset);
}
} else {
for (int i = 0; i < itemCount; ++i) {
notifyItemMoved(fromPosition + i + offset, toPosition + i + offset);
}
}
}
}
}
@SafeVarargs
@SuppressWarnings("unchecked")
public ConcatAdapter(RecyclerView.Adapter<? extends RecyclerView.ViewHolder>... adapters) {
this.adapters = (RecyclerView.Adapter<RecyclerView.ViewHolder>[]) adapters;
final int size = adapters.length;
adapterObservers = new RecyclerView.AdapterDataObserver[size];
for (int i = 0; i < size; ++i) {
adapterObservers[i] = new InternalObserver(i);
}
offsets = new int[size];
}
private int getAdapterIndexForPosition(int position) {
int index = Arrays.binarySearch(offsets, position);
if (index < 0) {
return ~index - 1;
}
// If the array contains multiple identical values (empty adapters), return the index of the last one
do {
++index;
}
while ((index < offsets.length) && (offsets[index] == position));
return --index;
}
@Override
public int getItemViewType(int position) {
final int index = getAdapterIndexForPosition(position);
RecyclerView.Adapter<RecyclerView.ViewHolder> adapter = adapters[index];
int viewType = adapter.getItemViewType(position - offsets[index]);
if (viewTypeAdapters.get(viewType) == null) {
viewTypeAdapters.put(viewType, adapter);
}
return viewType;
}
@NonNull
@Override
public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
return viewTypeAdapters.get(viewType).onCreateViewHolder(parent, viewType);
}
@Override
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
final int index = getAdapterIndexForPosition(position);
adapters[index].onBindViewHolder(holder, position - offsets[index]);
}
@Override
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position, @NonNull List<Object> payloads) {
final int index = getAdapterIndexForPosition(position);
adapters[index].onBindViewHolder(holder, position - offsets[index], payloads);
}
@Override
public int getItemCount() {
if (totalItemCount == -1) {
int count = 0;
for (int i = 0, size = adapters.length; i < size; ++i) {
offsets[i] = count;
count += adapters[i].getItemCount();
}
totalItemCount = count;
}
return totalItemCount;
}
@Override
public void registerAdapterDataObserver(@NonNull RecyclerView.AdapterDataObserver observer) {
if (!hasObservers()) {
for (int i = 0, size = adapters.length; i < size; ++i) {
adapters[i].registerAdapterDataObserver(adapterObservers[i]);
}
}
super.registerAdapterDataObserver(observer);
}
@Override
public void unregisterAdapterDataObserver(@NonNull RecyclerView.AdapterDataObserver observer) {
super.unregisterAdapterDataObserver(observer);
if (!hasObservers()) {
for (int i = 0, size = adapters.length; i < size; ++i) {
adapters[i].unregisterAdapterDataObserver(adapterObservers[i]);
}
}
}
@Override
public void onAttachedToRecyclerView(@NonNull RecyclerView recyclerView) {
for (RecyclerView.Adapter<RecyclerView.ViewHolder> adapter : adapters) {
adapter.onAttachedToRecyclerView(recyclerView);
}
}
@Override
public void onDetachedFromRecyclerView(@NonNull RecyclerView recyclerView) {
for (RecyclerView.Adapter<RecyclerView.ViewHolder> adapter : adapters) {
adapter.onDetachedFromRecyclerView(recyclerView);
}
}
@Override
public void onViewAttachedToWindow(@NonNull RecyclerView.ViewHolder holder) {
viewTypeAdapters.get(holder.getItemViewType()).onViewAttachedToWindow(holder);
}
@Override
public void onViewDetachedFromWindow(@NonNull RecyclerView.ViewHolder holder) {
viewTypeAdapters.get(holder.getItemViewType()).onViewDetachedFromWindow(holder);
}
@Override
public void onViewRecycled(@NonNull RecyclerView.ViewHolder holder) {
viewTypeAdapters.get(holder.getItemViewType()).onViewRecycled(holder);
}
@Override
public boolean onFailedToRecycleView(@NonNull RecyclerView.ViewHolder holder) {
return viewTypeAdapters.get(holder.getItemViewType()).onFailedToRecycleView(holder);
}
}

View file

@ -0,0 +1,183 @@
package be.digitalia.fosdem.adapters
import android.util.SparseArray
import android.view.ViewGroup
import androidx.core.util.set
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.AdapterDataObserver
import java.util.*
/**
* Adapter which concatenates the items of multiple adapters.
* Doesn't support stable ids, but properly delegates changes notifications.
*
*
* Adapters may provide multiple view types but they must not overlap.
* It's recommended to always use the item layout id as view type.
*
* @author Christophe Beyls
*/
class ConcatAdapter(vararg adapters: RecyclerView.Adapter<*>) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
@Suppress("UNCHECKED_CAST")
private val adapters = adapters as Array<RecyclerView.Adapter<RecyclerView.ViewHolder>>
private val adapterObservers = Array<AdapterDataObserver>(adapters.size) { InternalObserver(it) }
private val offsets = IntArray(adapters.size)
private var totalItemCount = -1
private val viewTypeAdapters = SparseArray<RecyclerView.Adapter<RecyclerView.ViewHolder>>()
private inner class InternalObserver(private val adapterIndex: Int) : AdapterDataObserver() {
override fun onChanged() {
totalItemCount = -1
notifyDataSetChanged()
}
override fun onItemRangeChanged(positionStart: Int, itemCount: Int) {
if (totalItemCount != -1) {
notifyItemRangeChanged(positionStart + offsets[adapterIndex], itemCount)
}
}
override fun onItemRangeChanged(positionStart: Int, itemCount: Int, payload: Any?) {
if (totalItemCount != -1) {
notifyItemRangeChanged(positionStart + offsets[adapterIndex], itemCount, payload)
}
}
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
if (totalItemCount != -1) {
for (i in adapterIndex + 1 until offsets.size) {
offsets[i] += itemCount
}
totalItemCount += itemCount
notifyItemRangeInserted(positionStart + offsets[adapterIndex], itemCount)
}
}
override fun onItemRangeRemoved(positionStart: Int, itemCount: Int) {
if (totalItemCount != -1) {
for (i in adapterIndex + 1 until offsets.size) {
offsets[i] -= itemCount
}
totalItemCount -= itemCount
notifyItemRangeRemoved(positionStart + offsets[adapterIndex], itemCount)
}
}
override fun onItemRangeMoved(fromPosition: Int, toPosition: Int, itemCount: Int) {
if (totalItemCount != -1) {
val offset = offsets[adapterIndex]
when {
itemCount == 1 -> {
notifyItemMoved(fromPosition + offset, toPosition + offset)
}
fromPosition < toPosition -> {
for (i in itemCount - 1 downTo 0) {
notifyItemMoved(fromPosition + i + offset, toPosition + i + offset)
}
}
else -> {
for (i in 0 until itemCount) {
notifyItemMoved(fromPosition + i + offset, toPosition + i + offset)
}
}
}
}
}
}
private fun getAdapterIndexForPosition(position: Int): Int {
var index = Arrays.binarySearch(offsets, position)
if (index < 0) {
return index.inv() - 1
}
// If the array contains multiple identical values (empty adapters), return the index of the last one
do {
++index
} while (index < offsets.size && offsets[index] == position)
return --index
}
override fun getItemViewType(position: Int): Int {
val index = getAdapterIndexForPosition(position)
val adapter = adapters[index]
val viewType = adapter.getItemViewType(position - offsets[index])
if (viewTypeAdapters[viewType] == null) {
viewTypeAdapters[viewType] = adapter
}
return viewType
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return viewTypeAdapters[viewType]!!.onCreateViewHolder(parent, viewType)
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
val index = getAdapterIndexForPosition(position)
adapters[index].onBindViewHolder(holder, position - offsets[index])
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int, payloads: List<Any>) {
val index = getAdapterIndexForPosition(position)
adapters[index].onBindViewHolder(holder, position - offsets[index], payloads)
}
override fun getItemCount(): Int {
if (totalItemCount == -1) {
var count = 0
for (i in adapters.indices) {
offsets[i] = count
count += adapters[i].itemCount
}
totalItemCount = count
}
return totalItemCount
}
override fun registerAdapterDataObserver(observer: AdapterDataObserver) {
if (!hasObservers()) {
for (i in adapters.indices) {
adapters[i].registerAdapterDataObserver(adapterObservers[i])
}
}
super.registerAdapterDataObserver(observer)
}
override fun unregisterAdapterDataObserver(observer: AdapterDataObserver) {
super.unregisterAdapterDataObserver(observer)
if (!hasObservers()) {
for (i in adapters.indices) {
adapters[i].unregisterAdapterDataObserver(adapterObservers[i])
}
}
}
override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
for (adapter in adapters) {
adapter.onAttachedToRecyclerView(recyclerView)
}
}
override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) {
for (adapter in adapters) {
adapter.onDetachedFromRecyclerView(recyclerView)
}
}
override fun onViewAttachedToWindow(holder: RecyclerView.ViewHolder) {
viewTypeAdapters[holder.itemViewType].onViewAttachedToWindow(holder)
}
override fun onViewDetachedFromWindow(holder: RecyclerView.ViewHolder) {
viewTypeAdapters[holder.itemViewType].onViewDetachedFromWindow(holder)
}
override fun onViewRecycled(holder: RecyclerView.ViewHolder) {
viewTypeAdapters[holder.itemViewType].onViewRecycled(holder)
}
override fun onFailedToRecycleView(holder: RecyclerView.ViewHolder): Boolean {
return viewTypeAdapters[holder.itemViewType].onFailedToRecycleView(holder)
}
}

View file

@ -1,215 +0,0 @@
package be.digitalia.fosdem.adapters;
import android.content.Context;
import android.content.Intent;
import android.graphics.drawable.Drawable;
import android.text.SpannableString;
import android.text.Spanned;
import android.text.TextUtils;
import android.text.style.ForegroundColorSpan;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import java.text.DateFormat;
import java.util.Date;
import java.util.List;
import java.util.Map;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.content.res.AppCompatResources;
import androidx.core.content.ContextCompat;
import androidx.core.util.ObjectsCompat;
import androidx.core.widget.TextViewCompat;
import androidx.lifecycle.LifecycleOwner;
import androidx.lifecycle.Observer;
import androidx.paging.PagedListAdapter;
import androidx.recyclerview.widget.DiffUtil;
import androidx.recyclerview.widget.RecyclerView;
import be.digitalia.fosdem.R;
import be.digitalia.fosdem.activities.EventDetailsActivity;
import be.digitalia.fosdem.api.FosdemApi;
import be.digitalia.fosdem.model.Event;
import be.digitalia.fosdem.model.RoomStatus;
import be.digitalia.fosdem.model.StatusEvent;
import be.digitalia.fosdem.model.Track;
import be.digitalia.fosdem.utils.DateUtils;
public class EventsAdapter extends PagedListAdapter<StatusEvent, EventsAdapter.ViewHolder>
implements Observer<Map<String, RoomStatus>> {
private static final DiffUtil.ItemCallback<StatusEvent> DIFF_CALLBACK = new SimpleItemCallback<StatusEvent>() {
@Override
public boolean areContentsTheSame(@NonNull StatusEvent oldItem, @NonNull StatusEvent newItem) {
final Event oldEvent = oldItem.getEvent();
final Event newEvent = newItem.getEvent();
return ObjectsCompat.equals(oldEvent.getTitle(), newEvent.getTitle())
&& ObjectsCompat.equals(oldEvent.getPersonsSummary(), newEvent.getPersonsSummary())
&& ObjectsCompat.equals(oldEvent.getTrack(), newEvent.getTrack())
&& ObjectsCompat.equals(oldEvent.getDay(), newEvent.getDay())
&& ObjectsCompat.equals(oldEvent.getStartTime(), newEvent.getStartTime())
&& ObjectsCompat.equals(oldEvent.getEndTime(), newEvent.getEndTime())
&& ObjectsCompat.equals(oldEvent.getRoomName(), newEvent.getRoomName())
&& oldItem.isBookmarked() == newItem.isBookmarked();
}
};
private static final Object DETAILS_PAYLOAD = new Object();
private final DateFormat timeDateFormat;
private final boolean showDay;
private Map<String, RoomStatus> roomStatuses;
public EventsAdapter(Context context, LifecycleOwner owner) {
this(context, owner, true);
}
public EventsAdapter(Context context, LifecycleOwner owner, boolean showDay) {
super(DIFF_CALLBACK);
timeDateFormat = DateUtils.getTimeDateFormat(context);
this.showDay = showDay;
FosdemApi.getRoomStatuses(context).observe(owner, this);
}
@Override
public void onChanged(@Nullable Map<String, RoomStatus> roomStatuses) {
this.roomStatuses = roomStatuses;
notifyItemRangeChanged(0, getItemCount(), DETAILS_PAYLOAD);
}
@Override
public int getItemViewType(int position) {
return R.layout.item_event;
}
@NonNull
@Override
public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_event, parent, false);
return new ViewHolder(view, timeDateFormat);
}
private RoomStatus getRoomStatus(Event event) {
return (roomStatuses == null) ? null : roomStatuses.get(event.getRoomName());
}
@Override
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
final StatusEvent statusEvent = getItem(position);
if (statusEvent == null) {
holder.clear();
} else {
final Event event = statusEvent.getEvent();
holder.bind(event, statusEvent.isBookmarked());
holder.bindDetails(event, showDay, getRoomStatus(event));
}
}
@Override
public void onBindViewHolder(@NonNull ViewHolder holder, int position, @NonNull List<Object> payloads) {
if (payloads.isEmpty()) {
onBindViewHolder(holder, position);
} else {
final StatusEvent statusEvent = getItem(position);
if (statusEvent != null) {
if (payloads.contains(DETAILS_PAYLOAD)) {
final Event event = statusEvent.getEvent();
holder.bindDetails(event, showDay, getRoomStatus(event));
}
}
}
}
static class ViewHolder extends RecyclerView.ViewHolder implements View.OnClickListener {
final TextView title;
final TextView persons;
final TextView trackName;
final TextView details;
private final DateFormat timeDateFormat;
Event event;
ViewHolder(View itemView, @NonNull DateFormat timeDateFormat) {
super(itemView);
title = itemView.findViewById(R.id.title);
persons = itemView.findViewById(R.id.persons);
trackName = itemView.findViewById(R.id.track_name);
details = itemView.findViewById(R.id.details);
itemView.setOnClickListener(this);
this.timeDateFormat = timeDateFormat;
}
void clear() {
this.event = null;
title.setText(null);
persons.setText(null);
trackName.setText(null);
details.setText(null);
}
void bind(@NonNull Event event, boolean isBookmarked) {
Context context = itemView.getContext();
this.event = event;
title.setText(event.getTitle());
Drawable bookmarkDrawable = isBookmarked
? AppCompatResources.getDrawable(context, R.drawable.ic_bookmark_24dp)
: null;
TextViewCompat.setCompoundDrawablesRelativeWithIntrinsicBounds(title, null, null, bookmarkDrawable, null);
title.setContentDescription(isBookmarked
? context.getString(R.string.in_bookmarks_content_description, event.getTitle())
: null
);
String personsSummary = event.getPersonsSummary();
persons.setText(personsSummary);
persons.setVisibility(TextUtils.isEmpty(personsSummary) ? View.GONE : View.VISIBLE);
Track track = event.getTrack();
trackName.setText(track.getName());
trackName.setTextColor(ContextCompat.getColorStateList(context, track.getType().getTextColorResId()));
trackName.setContentDescription(context.getString(R.string.track_content_description, track.getName()));
}
void bindDetails(@NonNull Event event, boolean showDay, @Nullable RoomStatus roomStatus) {
Context context = details.getContext();
Date startTime = event.getStartTime();
Date endTime = event.getEndTime();
String startTimeString = (startTime != null) ? timeDateFormat.format(startTime) : "?";
String endTimeString = (endTime != null) ? timeDateFormat.format(endTime) : "?";
String roomName = event.getRoomName();
CharSequence detailsText;
if (showDay) {
detailsText = String.format("%1$s, %2$s ― %3$s | %4$s", event.getDay().getShortName(), startTimeString, endTimeString, roomName);
} else {
detailsText = String.format("%1$s ― %2$s | %3$s", startTimeString, endTimeString, roomName);
}
CharSequence detailsDescription = detailsText;
if (roomStatus != null) {
SpannableString detailsSpannable = new SpannableString(detailsText);
int color = ContextCompat.getColor(context, roomStatus.getColorResId());
detailsSpannable.setSpan(new ForegroundColorSpan(color),
detailsText.length() - roomName.length(),
detailsText.length(),
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
detailsText = detailsSpannable;
detailsDescription = String.format("%1$s (%2$s)", detailsDescription, context.getString(roomStatus.getNameResId()));
}
details.setText(detailsText);
details.setContentDescription(context.getString(R.string.details_content_description, detailsDescription));
}
@Override
public void onClick(View view) {
if (event != null) {
Context context = view.getContext();
Intent intent = new Intent(context, EventDetailsActivity.class)
.putExtra(EventDetailsActivity.EXTRA_EVENT, event);
context.startActivity(intent);
}
}
}
}

View file

@ -0,0 +1,158 @@
package be.digitalia.fosdem.adapters
import android.content.Context
import android.content.Intent
import android.text.SpannableString
import android.text.style.ForegroundColorSpan
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.appcompat.content.res.AppCompatResources
import androidx.core.content.ContextCompat
import androidx.core.text.set
import androidx.core.view.isGone
import androidx.core.widget.TextViewCompat
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.observe
import androidx.paging.PagedListAdapter
import androidx.recyclerview.widget.RecyclerView
import be.digitalia.fosdem.R
import be.digitalia.fosdem.activities.EventDetailsActivity
import be.digitalia.fosdem.api.FosdemApi
import be.digitalia.fosdem.model.Event
import be.digitalia.fosdem.model.RoomStatus
import be.digitalia.fosdem.model.StatusEvent
import be.digitalia.fosdem.utils.DateUtils
import java.text.DateFormat
class EventsAdapter constructor(context: Context, owner: LifecycleOwner, private val showDay: Boolean = true)
: PagedListAdapter<StatusEvent, EventsAdapter.ViewHolder>(DIFF_CALLBACK) {
private val timeDateFormat = DateUtils.getTimeDateFormat(context)
private var roomStatuses: Map<String, RoomStatus>? = null
init {
FosdemApi.getRoomStatuses(context).observe(owner) { statuses ->
roomStatuses = statuses
notifyItemRangeChanged(0, itemCount, DETAILS_PAYLOAD)
}
}
override fun getItemViewType(position: Int) = R.layout.item_event
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(parent.context).inflate(R.layout.item_event, parent, false)
return ViewHolder(view, timeDateFormat)
}
private fun getRoomStatus(event: Event): RoomStatus? {
return roomStatuses?.let { it[event.roomName] }
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val statusEvent = getItem(position)
if (statusEvent == null) {
holder.clear()
} else {
val event = statusEvent.event
holder.bind(event, statusEvent.isBookmarked)
holder.bindDetails(event, showDay, getRoomStatus(event))
}
}
override fun onBindViewHolder(holder: ViewHolder, position: Int, payloads: List<Any>) {
if (payloads.isEmpty()) {
onBindViewHolder(holder, position)
} else {
val statusEvent = getItem(position)
if (statusEvent != null) {
if (DETAILS_PAYLOAD in payloads) {
val event = statusEvent.event
holder.bindDetails(event, showDay, getRoomStatus(event))
}
}
}
}
class ViewHolder(itemView: View, private val timeDateFormat: DateFormat)
: RecyclerView.ViewHolder(itemView), View.OnClickListener {
private val title: TextView = itemView.findViewById(R.id.title)
private val persons: TextView = itemView.findViewById(R.id.persons)
private val trackName: TextView = itemView.findViewById(R.id.track_name)
private val details: TextView = itemView.findViewById(R.id.details)
private var event: Event? = null
init {
itemView.setOnClickListener(this)
}
fun clear() {
event = null
title.text = null
persons.text = null
trackName.text = null
details.text = null
}
fun bind(event: Event, isBookmarked: Boolean) {
val context = itemView.context
this.event = event
title.text = event.title
val bookmarkDrawable = if (isBookmarked) AppCompatResources.getDrawable(context, R.drawable.ic_bookmark_24dp) else null
TextViewCompat.setCompoundDrawablesRelativeWithIntrinsicBounds(title, null, null, bookmarkDrawable, null)
title.contentDescription = if (isBookmarked) {
context.getString(R.string.in_bookmarks_content_description, event.title ?: "")
} else null
val personsSummary = event.personsSummary
persons.text = personsSummary
persons.isGone = personsSummary.isNullOrEmpty()
val track = event.track
trackName.text = track.name
trackName.setTextColor(ContextCompat.getColorStateList(context, track.type.textColorResId))
trackName.contentDescription = context.getString(R.string.track_content_description, track.name)
}
fun bindDetails(event: Event, showDay: Boolean, roomStatus: RoomStatus?) {
val context = details.context
val startTime = event.startTime
val endTime = event.endTime
val startTimeString = if (startTime != null) timeDateFormat.format(startTime) else "?"
val endTimeString = if (endTime != null) timeDateFormat.format(endTime) else "?"
val roomName = event.roomName ?: ""
var detailsText: CharSequence = if (showDay) {
"${event.day.shortName}, $startTimeString$endTimeString | $roomName"
} else {
"$startTimeString$endTimeString | $roomName"
}
var detailsDescription = detailsText
if (roomStatus != null) {
val color = ContextCompat.getColor(context, roomStatus.colorResId)
detailsText = SpannableString(detailsText).apply {
this[detailsText.length - roomName.length, detailsText.length] = ForegroundColorSpan(color)
}
detailsDescription = "$detailsDescription (${context.getString(roomStatus.nameResId)})"
}
details.text = detailsText
details.contentDescription = context.getString(R.string.details_content_description, detailsDescription)
}
override fun onClick(view: View) {
event?.let {
val context = view.context
val intent = Intent(context, EventDetailsActivity::class.java)
.putExtra(EventDetailsActivity.EXTRA_EVENT, it)
context.startActivity(intent)
}
}
}
companion object {
val DIFF_CALLBACK = createSimpleItemCallback<StatusEvent> { oldItem, newItem ->
oldItem.event.id == newItem.event.id
}
private val DETAILS_PAYLOAD = Any()
}
}

View file

@ -1,15 +0,0 @@
package be.digitalia.fosdem.adapters;
import androidx.annotation.NonNull;
import androidx.core.util.ObjectsCompat;
import androidx.recyclerview.widget.DiffUtil;
/**
* Implementation of DiffUtil.ItemCallback which uses Object.equals() to determine if items are the same.
*/
public abstract class SimpleItemCallback<T> extends DiffUtil.ItemCallback<T> {
@Override
public boolean areItemsTheSame(@NonNull T oldItem, @NonNull T newItem) {
return ObjectsCompat.equals(oldItem, newItem);
}
}

View file

@ -0,0 +1,21 @@
package be.digitalia.fosdem.adapters
import android.annotation.SuppressLint
import androidx.recyclerview.widget.DiffUtil
/**
* Creates a DiffUtil.ItemCallback instance using the provided lambda to determine
* if items are the same and using equals() to determine if item contents are the same.
*/
inline fun <T : Any> createSimpleItemCallback(crossinline areItemsTheSame: (oldItem: T, newItem: T) -> Boolean): DiffUtil.ItemCallback<T> {
return object : DiffUtil.ItemCallback<T>() {
override fun areItemsTheSame(oldItem: T, newItem: T): Boolean {
return areItemsTheSame(oldItem, newItem)
}
@SuppressLint("DiffUtilEquals")
override fun areContentsTheSame(oldItem: T, newItem: T): Boolean {
return oldItem == newItem
}
}
}

View file

@ -1,228 +0,0 @@
package be.digitalia.fosdem.adapters;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.LayerDrawable;
import android.text.TextUtils;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import java.text.DateFormat;
import java.util.List;
import androidx.annotation.DrawableRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.content.res.AppCompatResources;
import androidx.core.content.ContextCompat;
import androidx.core.util.ObjectsCompat;
import androidx.core.widget.TextViewCompat;
import androidx.recyclerview.widget.DiffUtil;
import androidx.recyclerview.widget.ListAdapter;
import androidx.recyclerview.widget.RecyclerView;
import be.digitalia.fosdem.R;
import be.digitalia.fosdem.model.Event;
import be.digitalia.fosdem.model.StatusEvent;
import be.digitalia.fosdem.utils.DateUtils;
public class TrackScheduleAdapter extends ListAdapter<StatusEvent, TrackScheduleAdapter.ViewHolder> {
public interface EventClickListener {
void onEventClick(int position, Event event);
}
private static final DiffUtil.ItemCallback<StatusEvent> DIFF_CALLBACK = new SimpleItemCallback<StatusEvent>() {
@Override
public boolean areContentsTheSame(@NonNull StatusEvent oldItem, @NonNull StatusEvent newItem) {
final Event oldEvent = oldItem.getEvent();
final Event newEvent = newItem.getEvent();
return ObjectsCompat.equals(oldEvent.getTitle(), newEvent.getTitle())
&& ObjectsCompat.equals(oldEvent.getPersonsSummary(), newEvent.getPersonsSummary())
&& ObjectsCompat.equals(oldEvent.getRoomName(), newEvent.getRoomName())
&& ObjectsCompat.equals(oldEvent.getStartTime(), newEvent.getStartTime())
&& oldItem.isBookmarked() == newItem.isBookmarked();
}
};
private static final Object TIME_COLORS_PAYLOAD = new Object();
private static final Object SELECTION_PAYLOAD = new Object();
final DateFormat timeDateFormat;
final int timeBackgroundColor;
final int timeForegroundColor;
final int timeRunningBackgroundColor;
final int timeRunningForegroundColor;
@Nullable
final EventClickListener listener;
private long currentTime = -1L;
private long selectedId = -1L;
public TrackScheduleAdapter(Context context, @Nullable EventClickListener listener) {
super(DIFF_CALLBACK);
setHasStableIds(true);
timeDateFormat = DateUtils.getTimeDateFormat(context);
timeBackgroundColor = ContextCompat.getColor(context, R.color.schedule_time_background);
timeRunningBackgroundColor = ContextCompat.getColor(context, R.color.schedule_time_running_background);
TypedArray a = context.getTheme().obtainStyledAttributes(R.styleable.PrimaryTextColors);
timeForegroundColor = a.getColor(R.styleable.PrimaryTextColors_android_textColorPrimary, 0);
timeRunningForegroundColor = a.getColor(R.styleable.PrimaryTextColors_android_textColorPrimaryInverse, 0);
a.recycle();
this.listener = listener;
}
/**
* @return The position of the item id in the current data set, or -1 if not found.
*/
public int getPositionForId(long id) {
if (id != -1) {
final int count = getItemCount();
for (int i = 0; i < count; ++i) {
if (getItemId(i) == id) {
return i;
}
}
}
return RecyclerView.NO_POSITION;
}
public void setCurrentTime(long time) {
if (currentTime != time) {
currentTime = time;
notifyItemRangeChanged(0, getItemCount(), TIME_COLORS_PAYLOAD);
}
}
public void setSelectedId(long newId) {
final long oldId = selectedId;
if (oldId != newId) {
selectedId = newId;
final int count = getItemCount();
for (int i = 0; i < count; ++i) {
final long id = getItemId(i);
if (id == oldId || id == newId) {
notifyItemChanged(i, SELECTION_PAYLOAD);
}
}
}
}
@Override
public long getItemId(int position) {
return getItem(position).getEvent().getId();
}
@NonNull
@Override
public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_schedule_event, parent, false);
return new ViewHolder(view, R.drawable.activated_background);
}
@Override
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
final StatusEvent statusEvent = getItem(position);
final Event event = statusEvent.getEvent();
holder.bind(event, statusEvent.isBookmarked());
holder.bindTimeColors(event, currentTime);
holder.bindSelection(event.getId() == selectedId);
}
@Override
public void onBindViewHolder(@NonNull ViewHolder holder, int position, @NonNull List<Object> payloads) {
if (payloads.isEmpty()) {
onBindViewHolder(holder, position);
} else {
final StatusEvent statusEvent = getItem(position);
if (payloads.contains(TIME_COLORS_PAYLOAD)) {
holder.bindTimeColors(statusEvent.getEvent(), currentTime);
}
if (payloads.contains(SELECTION_PAYLOAD)) {
holder.bindSelection(statusEvent.getEvent().getId() == selectedId);
}
}
}
class ViewHolder extends RecyclerView.ViewHolder implements View.OnClickListener {
final TextView time;
final TextView title;
final TextView persons;
final TextView room;
Event event;
ViewHolder(@NonNull View itemView, @DrawableRes int activatedBackgroundResId) {
super(itemView);
time = itemView.findViewById(R.id.time);
title = itemView.findViewById(R.id.title);
persons = itemView.findViewById(R.id.persons);
room = itemView.findViewById(R.id.room);
itemView.setOnClickListener(this);
if (activatedBackgroundResId != 0) {
// Compose a new background drawable by combining the existing one with the activated background
final Drawable existingBackground = itemView.getBackground();
final Drawable activatedBackground = ContextCompat.getDrawable(itemView.getContext(), activatedBackgroundResId);
Drawable newBackground;
if (existingBackground == null) {
newBackground = activatedBackground;
} else {
// Clear the existing background drawable callback so it can be assigned to the LayerDrawable
itemView.setBackground(null);
newBackground = new LayerDrawable(new Drawable[]{existingBackground, activatedBackground});
}
itemView.setBackground(newBackground);
}
}
void bind(@NonNull Event event, boolean isBookmarked) {
Context context = itemView.getContext();
this.event = event;
time.setText(timeDateFormat.format(event.getStartTime()));
title.setText(event.getTitle());
Drawable bookmarkDrawable = isBookmarked
? AppCompatResources.getDrawable(context, R.drawable.ic_bookmark_24dp)
: null;
TextViewCompat.setCompoundDrawablesRelativeWithIntrinsicBounds(title, null, null, bookmarkDrawable, null);
title.setContentDescription(isBookmarked
? context.getString(R.string.in_bookmarks_content_description, event.getTitle())
: null
);
String personsSummary = event.getPersonsSummary();
persons.setText(personsSummary);
persons.setVisibility(TextUtils.isEmpty(personsSummary) ? View.GONE : View.VISIBLE);
room.setText(event.getRoomName());
room.setContentDescription(context.getString(R.string.room_content_description, event.getRoomName()));
}
void bindTimeColors(@NonNull Event event, long currentTime) {
if ((currentTime != -1L) && event.isRunningAtTime(currentTime)) {
// Contrast colors for running event
time.setBackgroundColor(timeRunningBackgroundColor);
time.setTextColor(timeRunningForegroundColor);
time.setContentDescription(time.getContext().getString(R.string.in_progress_content_description, time.getText()));
} else {
// Normal colors
time.setBackgroundColor(timeBackgroundColor);
time.setTextColor(timeForegroundColor);
// Use text as content description
time.setContentDescription(null);
}
}
void bindSelection(boolean isSelected) {
itemView.setActivated(isSelected);
}
@Override
public void onClick(View v) {
if (listener != null) {
listener.onEventClick(getAdapterPosition(), event);
}
}
}
}

View file

@ -0,0 +1,187 @@
package be.digitalia.fosdem.adapters
import android.content.Context
import android.graphics.drawable.LayerDrawable
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.annotation.ColorInt
import androidx.annotation.DrawableRes
import androidx.appcompat.content.res.AppCompatResources
import androidx.core.content.ContextCompat
import androidx.core.view.isGone
import androidx.core.widget.TextViewCompat
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import be.digitalia.fosdem.R
import be.digitalia.fosdem.model.Event
import be.digitalia.fosdem.model.StatusEvent
import be.digitalia.fosdem.utils.DateUtils
class TrackScheduleAdapter(context: Context, private val listener: EventClickListener? = null)
: ListAdapter<StatusEvent, TrackScheduleAdapter.ViewHolder>(EventsAdapter.DIFF_CALLBACK) {
interface EventClickListener {
fun onEventClick(position: Int, event: Event)
}
private val timeDateFormat = DateUtils.getTimeDateFormat(context)
@ColorInt
private val timeBackgroundColor: Int = ContextCompat.getColor(context, R.color.schedule_time_background)
@ColorInt
private val timeRunningBackgroundColor: Int = ContextCompat.getColor(context, R.color.schedule_time_running_background)
@ColorInt
private val timeForegroundColor: Int
@ColorInt
private val timeRunningForegroundColor: Int
init {
setHasStableIds(true)
with(context.theme.obtainStyledAttributes(R.styleable.PrimaryTextColors)) {
timeForegroundColor = getColor(R.styleable.PrimaryTextColors_android_textColorPrimary, 0)
timeRunningForegroundColor = getColor(R.styleable.PrimaryTextColors_android_textColorPrimaryInverse, 0)
recycle()
}
}
/**
* @return The position of the item id in the current data set, or -1 if not found.
*/
fun getPositionForId(id: Long): Int {
if (id != RecyclerView.NO_ID) {
for (i in 0 until itemCount) {
if (getItemId(i) == id) {
return i
}
}
}
return RecyclerView.NO_POSITION
}
var currentTime: Long = -1L
set(value) {
if (field != value) {
field = value
notifyItemRangeChanged(0, itemCount, TIME_COLORS_PAYLOAD)
}
}
var selectedId: Long = RecyclerView.NO_ID
set(value) {
val oldId = field
if (oldId != value) {
field = value
for (i in 0 until itemCount) {
val id = getItemId(i)
if (id == oldId || id == value) {
notifyItemChanged(i, SELECTION_PAYLOAD)
}
}
}
}
override fun getItemId(position: Int) = getItem(position).event.id
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(parent.context).inflate(R.layout.item_schedule_event, parent, false)
return ViewHolder(view, R.drawable.activated_background)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val statusEvent = getItem(position)
val event = statusEvent.event
holder.bind(event, statusEvent.isBookmarked)
holder.bindTimeColors(event, currentTime)
holder.bindSelection(event.id == selectedId)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int, payloads: List<Any>) {
if (payloads.isEmpty()) {
onBindViewHolder(holder, position)
} else {
val statusEvent = getItem(position)
if (TIME_COLORS_PAYLOAD in payloads) {
holder.bindTimeColors(statusEvent.event, currentTime)
}
if (SELECTION_PAYLOAD in payloads) {
holder.bindSelection(statusEvent.event.id == selectedId)
}
}
}
inner class ViewHolder(itemView: View, @DrawableRes activatedBackgroundResId: Int)
: RecyclerView.ViewHolder(itemView), View.OnClickListener {
private val time: TextView = itemView.findViewById(R.id.time)
private val title: TextView = itemView.findViewById(R.id.title)
private val persons: TextView = itemView.findViewById(R.id.persons)
private val room: TextView = itemView.findViewById(R.id.room)
private var event: Event? = null
init {
itemView.setOnClickListener(this)
if (activatedBackgroundResId != 0) {
// Compose a new background drawable by combining the existing one with the activated background
val existingBackground = itemView.background
val activatedBackground = ContextCompat.getDrawable(itemView.context, activatedBackgroundResId)
val newBackground = if (existingBackground == null) {
activatedBackground
} else {
// Clear the existing background drawable callback so it can be assigned to the LayerDrawable
itemView.background = null
LayerDrawable(arrayOf(existingBackground, activatedBackground))
}
itemView.background = newBackground
}
}
fun bind(event: Event, isBookmarked: Boolean) {
val context = itemView.context
this.event = event
time.text = event.startTime?.let { timeDateFormat.format(it) }
title.text = event.title
val bookmarkDrawable = if (isBookmarked) AppCompatResources.getDrawable(context, R.drawable.ic_bookmark_24dp) else null
TextViewCompat.setCompoundDrawablesRelativeWithIntrinsicBounds(title, null, null, bookmarkDrawable, null)
title.contentDescription = if (isBookmarked) {
context.getString(R.string.in_bookmarks_content_description, event.title ?: "")
} else null
val personsSummary = event.personsSummary
persons.text = personsSummary
persons.isGone = personsSummary.isNullOrEmpty()
room.text = event.roomName
room.contentDescription = context.getString(R.string.room_content_description, event.roomName
?: "")
}
fun bindTimeColors(event: Event, currentTime: Long) {
if (currentTime != -1L && event.isRunningAtTime(currentTime)) {
// Contrast colors for running event
time.setBackgroundColor(timeRunningBackgroundColor)
time.setTextColor(timeRunningForegroundColor)
time.contentDescription = time.context.getString(R.string.in_progress_content_description, time.text)
} else {
// Normal colors
time.setBackgroundColor(timeBackgroundColor)
time.setTextColor(timeForegroundColor)
// Use text as content description
time.contentDescription = null
}
}
fun bindSelection(isSelected: Boolean) {
itemView.isActivated = isSelected
}
override fun onClick(v: View) {
event?.let { listener?.onEventClick(adapterPosition, it) }
}
}
companion object {
private val TIME_COLORS_PAYLOAD = Any()
private val SELECTION_PAYLOAD = Any()
}
}

View file

@ -1,97 +0,0 @@
package be.digitalia.fosdem.alarms;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.SharedPreferences.OnSharedPreferenceChangeListener;
import androidx.preference.PreferenceManager;
import be.digitalia.fosdem.fragments.SettingsFragment;
import be.digitalia.fosdem.model.Event;
import be.digitalia.fosdem.services.AlarmIntentService;
import java.util.Date;
/**
* This class monitors bookmarks and preferences changes to dispatch alarm update work to AlarmIntentService.
*
* @author Christophe Beyls
*/
public class FosdemAlarmManager implements OnSharedPreferenceChangeListener {
private static FosdemAlarmManager instance;
private final Context context;
private volatile boolean isEnabled;
public static void init(Context context) {
if (instance == null) {
instance = new FosdemAlarmManager(context);
}
}
public static FosdemAlarmManager getInstance() {
return instance;
}
private FosdemAlarmManager(Context context) {
this.context = context.getApplicationContext();
SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this.context);
isEnabled = sharedPreferences.getBoolean(SettingsFragment.KEY_PREF_NOTIFICATIONS_ENABLED, false);
sharedPreferences.registerOnSharedPreferenceChangeListener(this);
}
public boolean isEnabled() {
return isEnabled;
}
public void onScheduleRefreshed() {
if (isEnabled) {
startUpdateAlarms();
}
}
public void onBookmarkAdded(Event event) {
if (isEnabled) {
Intent serviceIntent = new Intent(AlarmIntentService.ACTION_ADD_BOOKMARK)
.putExtra(AlarmIntentService.EXTRA_EVENT_ID, event.getId());
final Date startTime = event.getStartTime();
if (startTime != null) {
serviceIntent.putExtra(AlarmIntentService.EXTRA_EVENT_START_TIME, startTime.getTime());
}
AlarmIntentService.enqueueWork(context, serviceIntent);
}
}
public void onBookmarksRemoved(long[] eventIds) {
if (isEnabled) {
Intent serviceIntent = new Intent(AlarmIntentService.ACTION_REMOVE_BOOKMARKS)
.putExtra(AlarmIntentService.EXTRA_EVENT_IDS, eventIds);
AlarmIntentService.enqueueWork(context, serviceIntent);
}
}
@Override
public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) {
if (SettingsFragment.KEY_PREF_NOTIFICATIONS_ENABLED.equals(key)) {
final boolean isEnabled = sharedPreferences.getBoolean(SettingsFragment.KEY_PREF_NOTIFICATIONS_ENABLED, false);
this.isEnabled = isEnabled;
if (isEnabled) {
startUpdateAlarms();
} else {
startDisableAlarms();
}
} else if (SettingsFragment.KEY_PREF_NOTIFICATIONS_DELAY.equals(key)) {
startUpdateAlarms();
}
}
private void startUpdateAlarms() {
Intent serviceIntent = new Intent(AlarmIntentService.ACTION_UPDATE_ALARMS);
AlarmIntentService.enqueueWork(context, serviceIntent);
}
private void startDisableAlarms() {
Intent serviceIntent = new Intent(AlarmIntentService.ACTION_DISABLE_ALARMS);
AlarmIntentService.enqueueWork(context, serviceIntent);
}
}

View file

@ -0,0 +1,84 @@
package be.digitalia.fosdem.alarms
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences.OnSharedPreferenceChangeListener
import androidx.annotation.MainThread
import androidx.preference.PreferenceManager
import be.digitalia.fosdem.model.Event
import be.digitalia.fosdem.services.AlarmIntentService
import be.digitalia.fosdem.utils.PreferenceKeys
/**
* This class monitors bookmarks and preferences changes to dispatch alarm update work to AlarmIntentService.
*
* @author Christophe Beyls
*/
object FosdemAlarmManager {
private lateinit var context: Context
private val onSharedPreferenceChangeListener = OnSharedPreferenceChangeListener { sharedPreferences, key ->
when (key) {
PreferenceKeys.NOTIFICATIONS_ENABLED -> {
val isEnabled = sharedPreferences.getBoolean(PreferenceKeys.NOTIFICATIONS_ENABLED, false)
this.isEnabled = isEnabled
if (isEnabled) {
startUpdateAlarms()
} else {
startDisableAlarms()
}
}
PreferenceKeys.NOTIFICATIONS_DELAY -> startUpdateAlarms()
}
}
@MainThread
fun init(context: Context) {
this.context = context.applicationContext
val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this.context)
isEnabled = sharedPreferences.getBoolean(PreferenceKeys.NOTIFICATIONS_ENABLED, false)
sharedPreferences.registerOnSharedPreferenceChangeListener(onSharedPreferenceChangeListener)
}
var isEnabled: Boolean = false
private set
@MainThread
fun onScheduleRefreshed() {
if (isEnabled) {
startUpdateAlarms()
}
}
@MainThread
fun onBookmarkAdded(event: Event) {
if (isEnabled) {
val serviceIntent = Intent(AlarmIntentService.ACTION_ADD_BOOKMARK).apply {
putExtra(AlarmIntentService.EXTRA_EVENT_ID, event.id)
event.startTime?.let {
putExtra(AlarmIntentService.EXTRA_EVENT_START_TIME, it.time)
}
}
AlarmIntentService.enqueueWork(context, serviceIntent)
}
}
@MainThread
fun onBookmarksRemoved(eventIds: LongArray?) {
if (isEnabled) {
val serviceIntent = Intent(AlarmIntentService.ACTION_REMOVE_BOOKMARKS)
.putExtra(AlarmIntentService.EXTRA_EVENT_IDS, eventIds)
AlarmIntentService.enqueueWork(context, serviceIntent)
}
}
private fun startUpdateAlarms() {
val serviceIntent = Intent(AlarmIntentService.ACTION_UPDATE_ALARMS)
AlarmIntentService.enqueueWork(context, serviceIntent)
}
private fun startDisableAlarms() {
val serviceIntent = Intent(AlarmIntentService.ACTION_DISABLE_ALARMS)
AlarmIntentService.enqueueWork(context, serviceIntent)
}
}

View file

@ -1,135 +0,0 @@
package be.digitalia.fosdem.api;
import android.content.Context;
import android.os.AsyncTask;
import android.text.format.DateUtils;
import androidx.annotation.MainThread;
import androidx.annotation.NonNull;
import androidx.annotation.WorkerThread;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.Transformations;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicBoolean;
import be.digitalia.fosdem.db.AppDatabase;
import be.digitalia.fosdem.db.ScheduleDao;
import be.digitalia.fosdem.livedata.LiveDataFactory;
import be.digitalia.fosdem.livedata.SingleEvent;
import be.digitalia.fosdem.model.Day;
import be.digitalia.fosdem.model.DetailedEvent;
import be.digitalia.fosdem.model.DownloadScheduleResult;
import be.digitalia.fosdem.model.RoomStatus;
import be.digitalia.fosdem.parsers.EventsParser;
import be.digitalia.fosdem.utils.network.HttpUtils;
import okio.BufferedSource;
/**
* Main API entry point.
*
* @author Christophe Beyls
*/
public class FosdemApi {
// 8:30 (local time)
private static final long DAY_START_TIME = 8 * DateUtils.HOUR_IN_MILLIS + 30 * DateUtils.MINUTE_IN_MILLIS;
// 19:00 (local time)
private static final long DAY_END_TIME = 19 * DateUtils.HOUR_IN_MILLIS;
private static final AtomicBoolean isLoading = new AtomicBoolean();
private static final MutableLiveData<Integer> progress = new MutableLiveData<>();
private static final MutableLiveData<SingleEvent<DownloadScheduleResult>> result = new MutableLiveData<>();
private static LiveData<Map<String, RoomStatus>> roomStatuses;
/**
* Download & store the schedule to the database.
* Only one thread at a time will perform the actual action, the other ones will return immediately.
* The result will be sent back in the consumable Result LiveData.
*/
@MainThread
public static void downloadSchedule(@NonNull Context context) {
if (!isLoading.compareAndSet(false, true)) {
// If a download is already in progress, return immediately
return;
}
final Context appContext = context.getApplicationContext();
AsyncTask.THREAD_POOL_EXECUTOR.execute(() -> {
downloadScheduleInternal(appContext);
isLoading.set(false);
});
}
@WorkerThread
private static void downloadScheduleInternal(@NonNull Context context) {
progress.postValue(-1);
DownloadScheduleResult res = DownloadScheduleResult.error();
try {
ScheduleDao scheduleDao = AppDatabase.getInstance(context).getScheduleDao();
HttpUtils.Response httpResponse = HttpUtils.get(
FosdemUrls.getSchedule(),
scheduleDao.getLastModifiedTag(),
progress::postValue);
if (httpResponse.source == null) {
// Nothing to parse, the result is up-to-date.
res = DownloadScheduleResult.upToDate();
return;
}
try (BufferedSource source = httpResponse.source) {
Iterable<DetailedEvent> events = new EventsParser().parse(source);
int count = scheduleDao.storeSchedule(events, httpResponse.lastModified);
res = DownloadScheduleResult.success(count);
}
} catch (Exception e) {
e.printStackTrace();
res = DownloadScheduleResult.error();
} finally {
progress.postValue(100);
result.postValue(new SingleEvent<>(res));
}
}
/**
* @return The current schedule download progress:
* -1 : in progress, indeterminate
* 0..99: progress value
* 100 : download complete or inactive
*/
public static LiveData<Integer> getDownloadScheduleProgress() {
return progress;
}
public static LiveData<SingleEvent<DownloadScheduleResult>> getDownloadScheduleResult() {
return result;
}
@MainThread
public static LiveData<Map<String, RoomStatus>> getRoomStatuses(@NonNull Context context) {
if (roomStatuses == null) {
// The room statuses will only be loaded when the event is live.
// Use the days from the database to determine it.
final LiveData<List<Day>> daysLiveData = AppDatabase.getInstance(context).getScheduleDao().getDays();
final LiveData<Boolean> scheduler = Transformations.switchMap(daysLiveData, days -> {
final long[] startEndTimestamps = new long[days.size() * 2];
int index = 0;
for (Day day : days) {
final long dayStart = day.getDate().getTime();
startEndTimestamps[index++] = dayStart + DAY_START_TIME;
startEndTimestamps[index++] = dayStart + DAY_END_TIME;
}
return LiveDataFactory.scheduler(startEndTimestamps);
});
final LiveData<Map<String, RoomStatus>> liveRoomStatuses = new LiveRoomStatusesLiveData();
final LiveData<Map<String, RoomStatus>> offlineRoomStatuses = new MutableLiveData<>(Collections.emptyMap());
roomStatuses = Transformations.switchMap(scheduler, isLive -> isLive ? liveRoomStatuses : offlineRoomStatuses);
// Implementors: replace the above code with the next line to disable room status support
// roomStatuses = new MutableLiveData<>();
}
return roomStatuses;
}
}

View file

@ -0,0 +1,187 @@
package be.digitalia.fosdem.api
import android.content.Context
import android.os.SystemClock
import android.text.format.DateUtils
import androidx.annotation.MainThread
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.liveData
import androidx.lifecycle.switchMap
import be.digitalia.fosdem.alarms.FosdemAlarmManager
import be.digitalia.fosdem.db.AppDatabase
import be.digitalia.fosdem.livedata.LiveDataFactory.scheduler
import be.digitalia.fosdem.livedata.SingleEvent
import be.digitalia.fosdem.model.DownloadScheduleResult
import be.digitalia.fosdem.model.RoomStatus
import be.digitalia.fosdem.parsers.EventsParser
import be.digitalia.fosdem.parsers.RoomStatusesParser
import be.digitalia.fosdem.utils.network.HttpUtils
import kotlinx.coroutines.*
import kotlin.math.pow
/**
* Main API entry point.
*
* @author Christophe Beyls
*/
object FosdemApi {
// 8:30 (local time)
private const val DAY_START_TIME = 8 * DateUtils.HOUR_IN_MILLIS + 30 * DateUtils.MINUTE_IN_MILLIS
// 19:00 (local time)
private const val DAY_END_TIME = 19 * DateUtils.HOUR_IN_MILLIS
private const val ROOM_STATUS_REFRESH_DELAY = 90L * DateUtils.SECOND_IN_MILLIS
private const val ROOM_STATUS_FIRST_RETRY_DELAY = 30L * DateUtils.SECOND_IN_MILLIS
private const val ROOM_STATUS_EXPIRATION_DELAY = 6L * DateUtils.MINUTE_IN_MILLIS
private var isLoading = false
private val _downloadScheduleProgress = MutableLiveData<Int>()
private val _downloadScheduleResult = MutableLiveData<SingleEvent<DownloadScheduleResult>>()
private var roomStatuses: LiveData<Map<String, RoomStatus>>? = null
/**
* Download & store the schedule to the database.
* Only one thread at a time will perform the actual action, the other ones will return immediately.
* The result will be sent back in the consumable Result LiveData.
*/
@MainThread
fun downloadSchedule(context: Context) {
if (isLoading) {
// If a download is already in progress, return immediately
return
}
isLoading = true
val appContext = context.applicationContext
GlobalScope.launch(Dispatchers.Main.immediate) {
downloadScheduleInternal(appContext)
isLoading = false
}
}
@MainThread
private suspend fun downloadScheduleInternal(context: Context) {
_downloadScheduleProgress.value = -1
val res = withContext(Dispatchers.IO) {
try {
val scheduleDao = AppDatabase.getInstance(context).scheduleDao
val httpResponse = HttpUtils.get(FosdemUrls.schedule, scheduleDao.lastModifiedTag) { percent ->
_downloadScheduleProgress.postValue(percent)
}
when (httpResponse) {
is HttpUtils.Response.NotModified -> {
// Nothing to parse, the result is up-to-date
DownloadScheduleResult.UpToDate
}
is HttpUtils.Response.Success -> {
httpResponse.source.use { source ->
val events = EventsParser().parse(source)
val count = scheduleDao.storeSchedule(events, httpResponse.lastModified)
DownloadScheduleResult.Success(count)
}
}
}
} catch (e: Exception) {
e.printStackTrace()
DownloadScheduleResult.Error
}
}
_downloadScheduleProgress.value = 100
if (res is DownloadScheduleResult.Success) {
FosdemAlarmManager.onScheduleRefreshed()
}
_downloadScheduleResult.value = SingleEvent(res)
}
/**
* @return The current schedule download progress:
* -1 : in progress, indeterminate
* 0..99: progress value
* 100 : download complete or inactive
*/
val downloadScheduleProgress: LiveData<Int>
get() = _downloadScheduleProgress
val downloadScheduleResult: LiveData<SingleEvent<DownloadScheduleResult>>
get() = _downloadScheduleResult
@MainThread
fun getRoomStatuses(context: Context): LiveData<Map<String, RoomStatus>> {
return roomStatuses ?: run {
// The room statuses will only be loaded when the event is live.
// Use the days from the database to determine it.
val scheduler = AppDatabase.getInstance(context).scheduleDao.days.switchMap { days ->
val startEndTimestamps = LongArray(days.size * 2)
var index = 0
for (day in days) {
val dayStart = day.date.time
startEndTimestamps[index++] = dayStart + DAY_START_TIME
startEndTimestamps[index++] = dayStart + DAY_END_TIME
}
scheduler(*startEndTimestamps)
}
val liveRoomStatuses = buildLiveRoomStatusesLiveData()
val offlineRoomStatuses = MutableLiveData(emptyMap<String, RoomStatus>())
scheduler.switchMap { isLive -> if (isLive) liveRoomStatuses else offlineRoomStatuses }
.also { roomStatuses = it }
// Implementors: replace the above code with the next line to disable room status support
// MutableLiveData().also { roomStatuses = it }
}
}
/**
* Builds a LiveData instance which loads and refreshes the Room statuses during the event.
*/
private fun buildLiveRoomStatusesLiveData(): LiveData<Map<String, RoomStatus>> {
var nextRefreshTime = 0L
var expirationTime = Long.MAX_VALUE
var retryAttempt = 0
return liveData {
var now = SystemClock.elapsedRealtime()
var nextRefreshDelay = nextRefreshTime - now
if (now > expirationTime && latestValue?.isEmpty() == false) {
// When the data expires, replace it with an empty value
emit(emptyMap())
}
while (true) {
if (nextRefreshDelay > 0) {
delay(nextRefreshDelay)
}
nextRefreshDelay = try {
val result = withContext(Dispatchers.IO) {
HttpUtils.get(FosdemUrls.rooms).use { source ->
RoomStatusesParser().parse(source)
}
}
now = SystemClock.elapsedRealtime()
retryAttempt = 0
expirationTime = now + ROOM_STATUS_EXPIRATION_DELAY
emit(result)
ROOM_STATUS_REFRESH_DELAY
} catch (e: Exception) {
if (e is CancellationException) {
throw e
}
now = SystemClock.elapsedRealtime()
if (now > expirationTime && latestValue?.isEmpty() == false) {
emit(emptyMap())
}
// Use exponential backoff for retries
val multiplier = 2.0.pow(retryAttempt).toLong()
retryAttempt++
(ROOM_STATUS_FIRST_RETRY_DELAY * multiplier).coerceAtMost(ROOM_STATUS_REFRESH_DELAY)
}
nextRefreshTime = now + nextRefreshDelay
}
}
}
}

View file

@ -1,47 +0,0 @@
package be.digitalia.fosdem.api;
import java.util.Locale;
/**
* This class contains all FOSDEM Urls
*
* @author Christophe Beyls
*/
public class FosdemUrls {
private static final String SCHEDULE_URL = "https://fosdem.org/schedule/xml";
private static final String ROOMS_URL = "https://api.fosdem.org/roomstatus/v1/listrooms";
private static final String EVENT_URL_FORMAT = "https://fosdem.org/%1$d/schedule/event/%2$s/";
private static final String PERSON_URL_FORMAT = "https://fosdem.org/%1$d/schedule/speaker/%2$s/";
private static final String LOCAL_NAVIGATION_URL = "https://nav.fosdem.org/";
private static final String LOCAL_NAVIGATION_TO_ROOM_URL_FORMAT = "https://nav.fosdem.org/d/%1$s/";
private static final String VOLUNTEER_URL = "https://fosdem.org/volunteer/";
public static String getSchedule() {
return SCHEDULE_URL;
}
public static String getRooms() {
return ROOMS_URL;
}
public static String getEvent(String slug, int year) {
return String.format(Locale.US, EVENT_URL_FORMAT, year, slug);
}
public static String getPerson(String slug, int year) {
return String.format(Locale.US, PERSON_URL_FORMAT, year, slug);
}
public static String getLocalNavigation() {
return LOCAL_NAVIGATION_URL;
}
public static String getLocalNavigationToLocation(String locationSlug) {
return String.format(Locale.US, LOCAL_NAVIGATION_TO_ROOM_URL_FORMAT, locationSlug);
}
public static String getVolunteer() {
return VOLUNTEER_URL;
}
}

View file

@ -0,0 +1,30 @@
package be.digitalia.fosdem.api
/**
* This class contains all FOSDEM Urls
*
* @author Christophe Beyls
*/
object FosdemUrls {
val schedule
get() = "https://fosdem.org/schedule/xml"
val rooms
get() = "https://api.fosdem.org/roomstatus/v1/listrooms"
val localNavigation
get() = "https://nav.fosdem.org/"
val volunteer
get() = "https://fosdem.org/volunteer/"
fun getEvent(slug: String, year: Int): String {
return "https://fosdem.org/$year/schedule/event/$slug/"
}
fun getPerson(slug: String, year: Int): String {
return "https://fosdem.org/$year/schedule/speaker/$slug/"
}
fun getLocalNavigationToLocation(locationSlug: String): String {
return "https://nav.fosdem.org/d/$locationSlug/"
}
}

View file

@ -1,133 +0,0 @@
package be.digitalia.fosdem.api;
import android.annotation.SuppressLint;
import android.os.AsyncTask;
import android.os.Handler;
import android.os.Looper;
import android.os.SystemClock;
import android.text.format.DateUtils;
import androidx.lifecycle.LiveData;
import java.util.Collections;
import java.util.Map;
import be.digitalia.fosdem.model.RoomStatus;
import be.digitalia.fosdem.parsers.RoomStatusesParser;
import be.digitalia.fosdem.utils.network.HttpUtils;
import okio.BufferedSource;
/**
* Loads and maintain the Room statuses live during the event.
*/
class LiveRoomStatusesLiveData extends LiveData<Map<String, RoomStatus>> {
private static final long REFRESH_DELAY = 90L * DateUtils.SECOND_IN_MILLIS;
private static final long FIRST_ERROR_REFRESH_DELAY = 30L * DateUtils.SECOND_IN_MILLIS;
private static final long EXPIRATION_DELAY = 6L * DateUtils.MINUTE_IN_MILLIS;
private static final int EXPIRE_WHAT = 0;
private static final int REFRESH_WHAT = 1;
private final Handler handler = new Handler(Looper.getMainLooper(), msg -> {
switch (msg.what) {
case EXPIRE_WHAT:
expire();
return true;
case REFRESH_WHAT:
refresh();
return true;
}
return false;
});
private long expirationTime = Long.MAX_VALUE;
private long nextRefreshTime = 0L;
private int retryAttempt = 0;
private AsyncTask<Void, Void, Map<String, RoomStatus>> currentTask = null;
@Override
protected void onActive() {
long now = SystemClock.elapsedRealtime();
if (expirationTime != Long.MAX_VALUE) {
if (now < expirationTime) {
handler.sendEmptyMessageDelayed(EXPIRE_WHAT, expirationTime - now);
} else {
expire();
}
}
if (now < nextRefreshTime) {
handler.sendEmptyMessageDelayed(REFRESH_WHAT, nextRefreshTime - now);
} else {
refresh();
}
}
@Override
protected void onInactive() {
handler.removeMessages(EXPIRE_WHAT);
handler.removeMessages(REFRESH_WHAT);
}
@SuppressLint("StaticFieldLeak")
void refresh() {
if (currentTask != null) {
// Let the ongoing task complete with success or error
return;
}
currentTask = new AsyncTask<Void, Void, Map<String, RoomStatus>>() {
@Override
protected Map<String, RoomStatus> doInBackground(Void... voids) {
try (BufferedSource source = HttpUtils.get(FosdemUrls.getRooms())) {
return new RoomStatusesParser().parse(source);
} catch (Throwable e) {
return null;
}
}
@Override
protected void onPostExecute(Map<String, RoomStatus> result) {
currentTask = null;
if (result != null) {
onSuccess(result);
} else {
onError();
}
}
};
currentTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
}
void onSuccess(Map<String, RoomStatus> result) {
setValue(result);
retryAttempt = 0;
long now = SystemClock.elapsedRealtime();
expirationTime = now + EXPIRATION_DELAY;
if (hasActiveObservers()) {
handler.sendEmptyMessageDelayed(EXPIRE_WHAT, EXPIRATION_DELAY);
}
scheduleNextRefresh(now, REFRESH_DELAY);
}
void onError() {
// Use exponential backoff for retries
long multiplier = (long) Math.pow(2, retryAttempt);
retryAttempt++;
scheduleNextRefresh(SystemClock.elapsedRealtime(),
Math.min(FIRST_ERROR_REFRESH_DELAY * multiplier, REFRESH_DELAY));
}
private void scheduleNextRefresh(long now, long delay) {
nextRefreshTime = now + delay;
if (hasActiveObservers()) {
handler.sendEmptyMessageDelayed(REFRESH_WHAT, delay);
}
}
void expire() {
// When the data expires, replace it with an empty value
setValue(Collections.emptyMap());
expirationTime = Long.MAX_VALUE;
}
}

View file

@ -1,100 +0,0 @@
package be.digitalia.fosdem.db;
import android.content.Context;
import android.content.SharedPreferences;
import androidx.annotation.NonNull;
import androidx.room.Database;
import androidx.room.Room;
import androidx.room.RoomDatabase;
import androidx.room.TypeConverters;
import androidx.room.migration.Migration;
import androidx.sqlite.db.SupportSQLiteDatabase;
import be.digitalia.fosdem.db.converters.GlobalTypeConverters;
import be.digitalia.fosdem.db.entities.Bookmark;
import be.digitalia.fosdem.db.entities.EventEntity;
import be.digitalia.fosdem.db.entities.EventTitles;
import be.digitalia.fosdem.db.entities.EventToPerson;
import be.digitalia.fosdem.model.Day;
import be.digitalia.fosdem.model.Link;
import be.digitalia.fosdem.model.Person;
import be.digitalia.fosdem.model.Track;
@Database(entities = {EventEntity.class, EventTitles.class, Person.class, EventToPerson.class, Link.class, Track.class, Day.class, Bookmark.class}, version = 2, exportSchema = false)
@TypeConverters({GlobalTypeConverters.class})
public abstract class AppDatabase extends RoomDatabase {
private static final String DB_PREFS_FILE = "database";
private static volatile AppDatabase INSTANCE;
static final Migration MIGRATION_1_2 = new Migration(1, 2) {
@Override
public void migrate(SupportSQLiteDatabase database) {
// Events: make primary key and track_id not null
database.execSQL("CREATE TABLE tmp_"
+ EventEntity.TABLE_NAME
+ " (id INTEGER PRIMARY KEY NOT NULL, day_index INTEGER NOT NULL, start_time INTEGER, end_time INTEGER, room_name TEXT, slug TEXT, track_id INTEGER NOT NULL, abstract TEXT, description TEXT);");
database.execSQL("INSERT INTO tmp_" + EventEntity.TABLE_NAME + " SELECT * FROM " + EventEntity.TABLE_NAME);
database.execSQL("DROP TABLE " + EventEntity.TABLE_NAME);
database.execSQL("ALTER TABLE tmp_" + EventEntity.TABLE_NAME + " RENAME TO " + EventEntity.TABLE_NAME);
database.execSQL("CREATE INDEX event_day_index_idx ON " + EventEntity.TABLE_NAME + " (day_index)");
database.execSQL("CREATE INDEX event_start_time_idx ON " + EventEntity.TABLE_NAME + " (start_time)");
database.execSQL("CREATE INDEX event_end_time_idx ON " + EventEntity.TABLE_NAME + " (end_time)");
database.execSQL("CREATE INDEX event_track_id_idx ON " + EventEntity.TABLE_NAME + " (track_id)");
// Links: add explicit primary key
database.execSQL("CREATE TABLE tmp_" + Link.TABLE_NAME + " (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, event_id INTEGER NOT NULL, url TEXT NOT NULL, description TEXT);");
database.execSQL("INSERT INTO tmp_" + Link.TABLE_NAME + " SELECT `rowid` AS id, event_id, url, description FROM " + Link.TABLE_NAME);
database.execSQL("DROP TABLE " + Link.TABLE_NAME);
database.execSQL("ALTER TABLE tmp_" + Link.TABLE_NAME + " RENAME TO " + Link.TABLE_NAME);
database.execSQL("CREATE INDEX link_event_id_idx ON " + Link.TABLE_NAME + " (event_id)");
// Tracks: make primary key not null
database.execSQL("CREATE TABLE tmp_" + Track.TABLE_NAME + " (id INTEGER PRIMARY KEY NOT NULL, name TEXT NOT NULL, type TEXT NOT NULL);");
database.execSQL("INSERT INTO tmp_" + Track.TABLE_NAME + " SELECT * FROM " + Track.TABLE_NAME);
database.execSQL("DROP TABLE " + Track.TABLE_NAME);
database.execSQL("ALTER TABLE tmp_" + Track.TABLE_NAME + " RENAME TO " + Track.TABLE_NAME);
database.execSQL("CREATE UNIQUE INDEX track_main_idx ON " + Track.TABLE_NAME + " (name, type)");
// Days: make primary key not null and rename _index to index
database.execSQL("CREATE TABLE tmp_" + Day.TABLE_NAME + " (`index` INTEGER PRIMARY KEY NOT NULL, date INTEGER NOT NULL);");
database.execSQL("INSERT INTO tmp_" + Day.TABLE_NAME + " SELECT _index as `index`, date FROM " + Day.TABLE_NAME);
database.execSQL("DROP TABLE " + Day.TABLE_NAME);
database.execSQL("ALTER TABLE tmp_" + Day.TABLE_NAME + " RENAME TO " + Day.TABLE_NAME);
// Bookmarks: make primary key not null
database.execSQL("CREATE TABLE tmp_" + Bookmark.TABLE_NAME + " (event_id INTEGER PRIMARY KEY NOT NULL);");
database.execSQL("INSERT INTO tmp_" + Bookmark.TABLE_NAME + " SELECT * FROM " + Bookmark.TABLE_NAME);
database.execSQL("DROP TABLE " + Bookmark.TABLE_NAME);
database.execSQL("ALTER TABLE tmp_" + Bookmark.TABLE_NAME + " RENAME TO " + Bookmark.TABLE_NAME);
}
};
private SharedPreferences sharedPreferences;
public SharedPreferences getSharedPreferences() {
return sharedPreferences;
}
public static AppDatabase getInstance(@NonNull Context context) {
AppDatabase res = INSTANCE;
if (res == null) {
synchronized (AppDatabase.class) {
res = INSTANCE;
if (res == null) {
res = Room.databaseBuilder(context.getApplicationContext(), AppDatabase.class, "fosdem.sqlite")
.addMigrations(MIGRATION_1_2)
.setJournalMode(JournalMode.TRUNCATE)
.build();
res.sharedPreferences = context.getApplicationContext().getSharedPreferences(DB_PREFS_FILE, Context.MODE_PRIVATE);
INSTANCE = res;
}
}
}
return res;
}
public abstract ScheduleDao getScheduleDao();
public abstract BookmarksDao getBookmarksDao();
}

View file

@ -0,0 +1,79 @@
package be.digitalia.fosdem.db
import android.content.Context
import android.content.SharedPreferences
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import be.digitalia.fosdem.db.converters.GlobalTypeConverters
import be.digitalia.fosdem.db.entities.Bookmark
import be.digitalia.fosdem.db.entities.EventEntity
import be.digitalia.fosdem.db.entities.EventTitles
import be.digitalia.fosdem.db.entities.EventToPerson
import be.digitalia.fosdem.model.Day
import be.digitalia.fosdem.model.Link
import be.digitalia.fosdem.model.Person
import be.digitalia.fosdem.model.Track
import be.digitalia.fosdem.utils.SingletonHolder
@Database(entities = [EventEntity::class, EventTitles::class, Person::class, EventToPerson::class,
Link::class, Track::class, Day::class, Bookmark::class], version = 2, exportSchema = false)
@TypeConverters(GlobalTypeConverters::class)
abstract class AppDatabase : RoomDatabase() {
lateinit var sharedPreferences: SharedPreferences
private set
abstract val scheduleDao: ScheduleDao
abstract val bookmarksDao: BookmarksDao
companion object : SingletonHolder<AppDatabase, Context>({ context ->
val DB_FILE = "fosdem.sqlite"
val DB_PREFS_FILE = "database"
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(database: SupportSQLiteDatabase) = with(database) {
// Events: make primary key and track_id not null
execSQL("CREATE TABLE tmp_${EventEntity.TABLE_NAME} (id INTEGER PRIMARY KEY NOT NULL, day_index INTEGER NOT NULL, start_time INTEGER, end_time INTEGER, room_name TEXT, slug TEXT, track_id INTEGER NOT NULL, abstract TEXT, description TEXT)")
execSQL("INSERT INTO tmp_${EventEntity.TABLE_NAME} SELECT * FROM ${EventEntity.TABLE_NAME}")
execSQL("DROP TABLE ${EventEntity.TABLE_NAME}")
execSQL("ALTER TABLE tmp_${EventEntity.TABLE_NAME} RENAME TO ${EventEntity.TABLE_NAME}")
execSQL("CREATE INDEX event_day_index_idx ON ${EventEntity.TABLE_NAME} (day_index)")
execSQL("CREATE INDEX event_start_time_idx ON ${EventEntity.TABLE_NAME} (start_time)")
execSQL("CREATE INDEX event_end_time_idx ON ${EventEntity.TABLE_NAME} (end_time)")
execSQL("CREATE INDEX event_track_id_idx ON ${EventEntity.TABLE_NAME} (track_id)")
// Links: add explicit primary key
execSQL("CREATE TABLE tmp_${Link.TABLE_NAME} (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, event_id INTEGER NOT NULL, url TEXT NOT NULL, description TEXT)")
execSQL("INSERT INTO tmp_${Link.TABLE_NAME} SELECT `rowid` AS id, event_id, url, description FROM ${Link.TABLE_NAME}")
execSQL("DROP TABLE ${Link.TABLE_NAME}")
execSQL("ALTER TABLE tmp_${Link.TABLE_NAME} RENAME TO ${Link.TABLE_NAME}")
execSQL("CREATE INDEX link_event_id_idx ON ${Link.TABLE_NAME} (event_id)")
// Tracks: make primary key not null
execSQL("CREATE TABLE tmp_${Track.TABLE_NAME} (id INTEGER PRIMARY KEY NOT NULL, name TEXT NOT NULL, type TEXT NOT NULL)")
execSQL("INSERT INTO tmp_${Track.TABLE_NAME} SELECT * FROM ${Track.TABLE_NAME}")
execSQL("DROP TABLE ${Track.TABLE_NAME}")
execSQL("ALTER TABLE tmp_${Track.TABLE_NAME} RENAME TO ${Track.TABLE_NAME}")
execSQL("CREATE UNIQUE INDEX track_main_idx ON ${Track.TABLE_NAME} (name, type)")
// Days: make primary key not null and rename _index to index
execSQL("CREATE TABLE tmp_${Day.TABLE_NAME} (`index` INTEGER PRIMARY KEY NOT NULL, date INTEGER NOT NULL)")
execSQL("INSERT INTO tmp_${Day.TABLE_NAME} SELECT _index as `index`, date FROM ${Day.TABLE_NAME}")
execSQL("DROP TABLE ${Day.TABLE_NAME}")
execSQL("ALTER TABLE tmp_${Day.TABLE_NAME} RENAME TO ${Day.TABLE_NAME}")
// Bookmarks: make primary key not null
execSQL("CREATE TABLE tmp_${Bookmark.TABLE_NAME} (event_id INTEGER PRIMARY KEY NOT NULL)")
execSQL("INSERT INTO tmp_${Bookmark.TABLE_NAME} SELECT * FROM ${Bookmark.TABLE_NAME}")
execSQL("DROP TABLE ${Bookmark.TABLE_NAME}")
execSQL("ALTER TABLE tmp_${Bookmark.TABLE_NAME} RENAME TO ${Bookmark.TABLE_NAME}")
}
}
Room.databaseBuilder(context.applicationContext, AppDatabase::class.java, DB_FILE)
.addMigrations(MIGRATION_1_2)
.setJournalMode(JournalMode.TRUNCATE)
.build().apply {
sharedPreferences = context.applicationContext.getSharedPreferences(DB_PREFS_FILE, Context.MODE_PRIVATE)
}
})
}

View file

@ -1,83 +0,0 @@
package be.digitalia.fosdem.db;
import androidx.annotation.NonNull;
import androidx.annotation.WorkerThread;
import androidx.lifecycle.LiveData;
import androidx.room.*;
import be.digitalia.fosdem.alarms.FosdemAlarmManager;
import be.digitalia.fosdem.db.entities.Bookmark;
import be.digitalia.fosdem.model.AlarmInfo;
import be.digitalia.fosdem.model.Event;
import java.util.List;
@Dao
public abstract class BookmarksDao {
/**
* Returns the bookmarks.
*
* @param minStartTime When greater than 0, only return the events starting after this time.
*/
@Query("SELECT e.id, e.start_time, e.end_time, e.room_name, e.slug, et.title, et.subtitle, e.abstract, e.description"
+ ", GROUP_CONCAT(p.name, ', ') AS persons, e.day_index, d.date AS day_date, e.track_id, t.name AS track_name, t.type AS track_type"
+ " FROM bookmarks b"
+ " JOIN events e ON b.event_id = e.id"
+ " JOIN events_titles et ON e.id = et.`rowid`"
+ " JOIN days d ON e.day_index = d.`index`"
+ " JOIN tracks t ON e.track_id = t.id"
+ " LEFT JOIN events_persons ep ON e.id = ep.event_id"
+ " LEFT JOIN persons p ON ep.person_id = p.`rowid`"
+ " WHERE e.start_time > :minStartTime"
+ " GROUP BY e.id"
+ " ORDER BY e.start_time ASC")
public abstract LiveData<List<Event>> getBookmarks(long minStartTime);
@Query("SELECT e.id, e.start_time, e.end_time, e.room_name, e.slug, et.title, et.subtitle, e.abstract, e.description"
+ ", GROUP_CONCAT(p.name, ', ') AS persons, e.day_index, d.date AS day_date, e.track_id, t.name AS track_name, t.type AS track_type"
+ " FROM bookmarks b"
+ " JOIN events e ON b.event_id = e.id"
+ " JOIN events_titles et ON e.id = et.`rowid`"
+ " JOIN days d ON e.day_index = d.`index`"
+ " JOIN tracks t ON e.track_id = t.id"
+ " LEFT JOIN events_persons ep ON e.id = ep.event_id"
+ " LEFT JOIN persons p ON ep.person_id = p.`rowid`"
+ " GROUP BY e.id"
+ " ORDER BY e.start_time ASC")
@WorkerThread
public abstract Event[] getBookmarks();
@Query("SELECT b.event_id, e.start_time"
+ " FROM bookmarks b"
+ " JOIN events e ON b.event_id = e.id"
+ " WHERE e.start_time > :minStartTime"
+ " ORDER BY e.start_time ASC")
@WorkerThread
public abstract AlarmInfo[] getBookmarksAlarmInfo(long minStartTime);
@Query("SELECT COUNT(*) FROM bookmarks WHERE event_id = :event")
public abstract LiveData<Boolean> getBookmarkStatus(Event event);
public void addBookmark(@NonNull Event event) {
if (addBookmarkInternal(new Bookmark(event.getId())) != -1L) {
FosdemAlarmManager.getInstance().onBookmarkAdded(event);
}
}
@Insert(onConflict = OnConflictStrategy.IGNORE)
protected abstract long addBookmarkInternal(Bookmark bookmark);
@Delete
public void removeBookmark(@NonNull Event event) {
removeBookmarks(event.getId());
}
public void removeBookmarks(@NonNull long... eventIds) {
if (removeBookmarksInternal(eventIds) > 0) {
FosdemAlarmManager.getInstance().onBookmarksRemoved(eventIds);
}
}
@Query("DELETE FROM bookmarks WHERE event_id IN (:eventIds)")
protected abstract int removeBookmarksInternal(long[] eventIds);
}

View file

@ -0,0 +1,89 @@
package be.digitalia.fosdem.db
import androidx.annotation.WorkerThread
import androidx.lifecycle.LiveData
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import be.digitalia.fosdem.alarms.FosdemAlarmManager
import be.digitalia.fosdem.db.entities.Bookmark
import be.digitalia.fosdem.model.AlarmInfo
import be.digitalia.fosdem.model.Event
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
@Dao
abstract class BookmarksDao {
/**
* Returns the bookmarks.
*
* @param minStartTime When greater than 0, only return the events starting after this time.
*/
@Query("""SELECT e.id, e.start_time, e.end_time, e.room_name, e.slug, et.title, et.subtitle, e.abstract, e.description,
GROUP_CONCAT(p.name, ', ') AS persons, e.day_index, d.date AS day_date, e.track_id, t.name AS track_name, t.type AS track_type
FROM bookmarks b
JOIN events e ON b.event_id = e.id
JOIN events_titles et ON e.id = et.`rowid`
JOIN days d ON e.day_index = d.`index`
JOIN tracks t ON e.track_id = t.id
LEFT JOIN events_persons ep ON e.id = ep.event_id
LEFT JOIN persons p ON ep.person_id = p.`rowid`
WHERE e.start_time > :minStartTime
GROUP BY e.id
ORDER BY e.start_time ASC""")
abstract fun getBookmarks(minStartTime: Long): LiveData<List<Event>>
@Query("""SELECT e.id, e.start_time, e.end_time, e.room_name, e.slug, et.title, et.subtitle, e.abstract, e.description,
GROUP_CONCAT(p.name, ', ') AS persons, e.day_index, d.date AS day_date, e.track_id, t.name AS track_name, t.type AS track_type
FROM bookmarks b
JOIN events e ON b.event_id = e.id
JOIN events_titles et ON e.id = et.`rowid`
JOIN days d ON e.day_index = d.`index`
JOIN tracks t ON e.track_id = t.id
LEFT JOIN events_persons ep ON e.id = ep.event_id
LEFT JOIN persons p ON ep.person_id = p.`rowid`
GROUP BY e.id
ORDER BY e.start_time ASC""")
@WorkerThread
abstract fun getBookmarks(): Array<Event>
@Query("""SELECT b.event_id, e.start_time
FROM bookmarks b
JOIN events e ON b.event_id = e.id
WHERE e.start_time > :minStartTime
ORDER BY e.start_time ASC""")
@WorkerThread
abstract fun getBookmarksAlarmInfo(minStartTime: Long): Array<AlarmInfo>
@Query("SELECT COUNT(*) FROM bookmarks WHERE event_id = :event")
abstract fun getBookmarkStatus(event: Event): LiveData<Boolean>
fun addBookmarkAsync(event: Event) {
GlobalScope.launch(Dispatchers.Main.immediate) {
if (addBookmarkInternal(Bookmark(event.id)) != -1L) {
FosdemAlarmManager.onBookmarkAdded(event)
}
}
}
@Insert(onConflict = OnConflictStrategy.IGNORE)
protected abstract suspend fun addBookmarkInternal(bookmark: Bookmark): Long
fun removeBookmarkAsync(event: Event) {
removeBookmarksAsync(event.id)
}
fun removeBookmarksAsync(vararg eventIds: Long) {
GlobalScope.launch(Dispatchers.Main.immediate) {
if (removeBookmarksInternal(eventIds) > 0) {
FosdemAlarmManager.onBookmarksRemoved(eventIds)
}
}
}
@Query("DELETE FROM bookmarks WHERE event_id IN (:eventIds)")
protected abstract suspend fun removeBookmarksInternal(eventIds: LongArray): Int
}

View file

@ -1,469 +0,0 @@
package be.digitalia.fosdem.db;
import android.app.SearchManager;
import android.database.Cursor;
import android.provider.BaseColumns;
import androidx.annotation.MainThread;
import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.paging.DataSource;
import androidx.room.*;
import be.digitalia.fosdem.alarms.FosdemAlarmManager;
import be.digitalia.fosdem.db.entities.EventEntity;
import be.digitalia.fosdem.db.entities.EventTitles;
import be.digitalia.fosdem.db.entities.EventToPerson;
import be.digitalia.fosdem.model.*;
import be.digitalia.fosdem.utils.DateUtils;
import java.util.*;
@Dao
public abstract class ScheduleDao {
private static final String LAST_UPDATE_TIME_PREF = "last_update_time";
private static final String LAST_MODIFIED_TAG_PREF = "last_modified_tag";
private final AppDatabase appDatabase;
private final MutableLiveData<Long> lastUpdateTime = new MutableLiveData<>();
public ScheduleDao(AppDatabase appDatabase) {
this.appDatabase = appDatabase;
}
/**
* @return The last update time in milliseconds since EPOCH, or -1 if not available.
* This LiveData is pre-initialized with the up-to-date value.
*/
@MainThread
public LiveData<Long> getLastUpdateTime() {
if (lastUpdateTime.getValue() == null) {
lastUpdateTime.setValue(appDatabase.getSharedPreferences().getLong(LAST_UPDATE_TIME_PREF, -1L));
}
return lastUpdateTime;
}
/**
* @return The time identifier of the current version of the database.
*/
public String getLastModifiedTag() {
return appDatabase.getSharedPreferences().getString(LAST_MODIFIED_TAG_PREF, null);
}
private static class EmptyScheduleException extends RuntimeException {
}
/**
* Stores the schedule in the database.
*
* @param events The events stream.
* @return The number of events processed.
*/
@WorkerThread
public int storeSchedule(Iterable<DetailedEvent> events, String lastModifiedTag) {
int totalEvents;
try {
totalEvents = storeScheduleInternal(events, lastModifiedTag);
} catch (EmptyScheduleException ese) {
totalEvents = 0;
}
if (totalEvents > 0) {
// Set last update time and server's last modified tag
final long now = System.currentTimeMillis();
appDatabase.getSharedPreferences().edit()
.putLong(LAST_UPDATE_TIME_PREF, now)
.putString(LAST_MODIFIED_TAG_PREF, lastModifiedTag)
.apply();
lastUpdateTime.postValue(now);
FosdemAlarmManager.getInstance().onScheduleRefreshed();
}
return totalEvents;
}
@Transaction
protected int storeScheduleInternal(Iterable<DetailedEvent> events, String lastModifiedTag) {
// 1: Delete the previous schedule
clearSchedule();
// 2: Insert the events
int totalEvents = 0;
final Map<Track, Long> tracks = new HashMap<>();
long nextTrackId = 0L;
long minEventId = Long.MAX_VALUE;
final Set<Day> days = new HashSet<>(2);
for (DetailedEvent event : events) {
// Retrieve or insert Track
final Track track = event.getTrack();
Long trackId = tracks.get(track);
if (trackId == null) {
// New track
nextTrackId++;
trackId = nextTrackId;
track.setId(nextTrackId);
insertTrack(track);
tracks.put(track, trackId);
} else {
track.setId(trackId);
}
final long eventId = event.getId();
try {
// Insert main event and fulltext fields
insertEvent(new EventEntity(event), new EventTitles(event));
} catch (Exception e) {
// Duplicate event: skip
continue;
}
days.add(event.getDay());
if (eventId < minEventId) {
minEventId = eventId;
}
final List<Person> persons = event.getPersons();
insertPersons(persons);
final int personsCount = persons.size();
final EventToPerson[] eventsToPersons = new EventToPerson[personsCount];
for (int i = 0; i < personsCount; ++i) {
eventsToPersons[i] = new EventToPerson(eventId, persons.get(i).getId());
}
insertEventsToPersons(eventsToPersons);
insertLinks(event.getLinks());
totalEvents++;
}
if (totalEvents == 0) {
// Rollback the transaction
throw new EmptyScheduleException();
}
// 3: Insert collected days
insertDays(days);
// 4: Purge outdated bookmarks
purgeOutdatedBookmarks(minEventId);
return totalEvents;
}
@Insert
protected abstract void insertTrack(Track track);
@Insert
protected abstract void insertEvent(EventEntity eventEntity, EventTitles eventTitles);
@Insert(onConflict = OnConflictStrategy.IGNORE)
protected abstract void insertPersons(List<Person> persons);
@Insert
protected abstract void insertEventsToPersons(EventToPerson[] eventsToPersons);
@Insert
protected abstract void insertLinks(List<Link> links);
@Insert
protected abstract void insertDays(Set<Day> days);
@Query("DELETE FROM bookmarks WHERE event_id < :minEventId")
protected abstract void purgeOutdatedBookmarks(long minEventId);
@WorkerThread
@Transaction
public void clearSchedule() {
clearEvents();
clearEventTitles();
clearPersons();
clearEventToPersons();
clearLinks();
clearTracks();
clearDays();
}
@Query("DELETE FROM events")
protected abstract void clearEvents();
@Query("DELETE FROM events_titles")
protected abstract void clearEventTitles();
@Query("DELETE FROM persons")
protected abstract void clearPersons();
@Query("DELETE FROM events_persons")
protected abstract void clearEventToPersons();
@Query("DELETE FROM links")
protected abstract void clearLinks();
@Query("DELETE FROM tracks")
protected abstract void clearTracks();
@Query("DELETE FROM days")
protected abstract void clearDays();
// Cache days
private volatile LiveData<List<Day>> daysLiveData;
public LiveData<List<Day>> getDays() {
if (daysLiveData != null) {
return daysLiveData;
}
synchronized (this) {
daysLiveData = getDaysInternal();
return daysLiveData;
}
}
@Query("SELECT `index`, date FROM days ORDER BY `index` ASC")
protected abstract LiveData<List<Day>> getDaysInternal();
@WorkerThread
public int getYear() {
long date = 0L;
// Compute from cached days if available
final LiveData<List<Day>> cache = daysLiveData;
List<Day> days = (cache == null) ? null : cache.getValue();
if (days != null) {
if (days.size() > 0) {
date = days.get(0).getDate().getTime();
}
} else {
date = getConferenceStartDate();
}
// Use the current year by default
if (date == 0L) {
date = System.currentTimeMillis();
}
return DateUtils.getYear(date);
}
@Query("SELECT date FROM days ORDER BY `index` ASC LIMIT 1")
protected abstract long getConferenceStartDate();
@Query("SELECT t.id, t.name, t.type FROM tracks t"
+ " JOIN events e ON t.id = e.track_id"
+ " WHERE e.day_index = :day"
+ " GROUP BY t.id"
+ " ORDER BY t.name ASC")
public abstract LiveData<List<Track>> getTracks(Day day);
/**
* Returns the event with the specified id, or null if not found.
*/
@Query("SELECT e.id, e.start_time, e.end_time, e.room_name, e.slug, et.title, et.subtitle, e.abstract, e.description"
+ ", GROUP_CONCAT(p.name, ', ') AS persons, e.day_index, d.date AS day_date, e.track_id, t.name AS track_name, t.type AS track_type"
+ " FROM events e"
+ " JOIN events_titles et ON e.id = et.`rowid`"
+ " JOIN days d ON e.day_index = d.`index`"
+ " JOIN tracks t ON e.track_id = t.id"
+ " LEFT JOIN events_persons ep ON e.id = ep.event_id"
+ " LEFT JOIN persons p ON ep.person_id = p.`rowid`"
+ " WHERE e.id = :id"
+ " GROUP BY e.id")
@Nullable
@WorkerThread
public abstract Event getEvent(long id);
/**
* Returns all found events whose id is part of the given list.
*/
@Query("SELECT e.id, e.start_time, e.end_time, e.room_name, e.slug, et.title, et.subtitle, e.abstract, e.description"
+ ", GROUP_CONCAT(p.name, ', ') AS persons, e.day_index, d.date AS day_date, e.track_id, t.name AS track_name, t.type AS track_type"
+ ", b.event_id IS NOT NULL AS is_bookmarked"
+ " FROM events e"
+ " JOIN events_titles et ON e.id = et.`rowid`"
+ " JOIN days d ON e.day_index = d.`index`"
+ " JOIN tracks t ON e.track_id = t.id"
+ " LEFT JOIN events_persons ep ON e.id = ep.event_id"
+ " LEFT JOIN persons p ON ep.person_id = p.`rowid`"
+ " LEFT JOIN bookmarks b ON e.id = b.event_id"
+ " WHERE e.id IN (:ids)"
+ " GROUP BY e.id"
+ " ORDER BY e.start_time ASC")
public abstract DataSource.Factory<Integer, StatusEvent> getEvents(long[] ids);
/**
* Returns the events for a specified track.
*/
@Query("SELECT e.id, e.start_time, e.end_time, e.room_name, e.slug, et.title, et.subtitle, e.abstract, e.description"
+ ", GROUP_CONCAT(p.name, ', ') AS persons, e.day_index, d.date AS day_date, e.track_id, t.name AS track_name, t.type AS track_type"
+ ", b.event_id IS NOT NULL AS is_bookmarked"
+ " FROM events e"
+ " JOIN events_titles et ON e.id = et.`rowid`"
+ " JOIN days d ON e.day_index = d.`index`"
+ " JOIN tracks t ON e.track_id = t.id"
+ " LEFT JOIN events_persons ep ON e.id = ep.event_id"
+ " LEFT JOIN persons p ON ep.person_id = p.`rowid`"
+ " LEFT JOIN bookmarks b ON e.id = b.event_id"
+ " WHERE e.day_index = :day AND e.track_id = :track"
+ " GROUP BY e.id"
+ " ORDER BY e.start_time ASC")
public abstract LiveData<List<StatusEvent>> getEvents(Day day, Track track);
/**
* Returns a snapshot of the events for a specified track (without the bookmark status).
*/
@Query("SELECT e.id, e.start_time, e.end_time, e.room_name, e.slug, et.title, et.subtitle, e.abstract, e.description"
+ ", GROUP_CONCAT(p.name, ', ') AS persons, e.day_index, d.date AS day_date, e.track_id, t.name AS track_name, t.type AS track_type"
+ " FROM events e"
+ " JOIN events_titles et ON e.id = et.`rowid`"
+ " JOIN days d ON e.day_index = d.`index`"
+ " JOIN tracks t ON e.track_id = t.id"
+ " LEFT JOIN events_persons ep ON e.id = ep.event_id"
+ " LEFT JOIN persons p ON ep.person_id = p.`rowid`"
+ " WHERE e.day_index = :day AND e.track_id = :track"
+ " GROUP BY e.id"
+ " ORDER BY e.start_time ASC")
public abstract List<Event> getEventsSnapshot(Day day, Track track);
/**
* Returns events starting in the specified interval, ordered by ascending start time.
*/
@Query("SELECT e.id, e.start_time, e.end_time, e.room_name, e.slug, et.title, et.subtitle, e.abstract, e.description"
+ ", GROUP_CONCAT(p.name, ', ') AS persons, e.day_index, d.date AS day_date, e.track_id, t.name AS track_name, t.type AS track_type"
+ ", b.event_id IS NOT NULL AS is_bookmarked"
+ " FROM events e"
+ " JOIN events_titles et ON e.id = et.`rowid`"
+ " JOIN days d ON e.day_index = d.`index`"
+ " JOIN tracks t ON e.track_id = t.id"
+ " LEFT JOIN events_persons ep ON e.id = ep.event_id"
+ " LEFT JOIN persons p ON ep.person_id = p.`rowid`"
+ " LEFT JOIN bookmarks b ON e.id = b.event_id"
+ " WHERE e.start_time BETWEEN :minStartTime AND :maxStartTime"
+ " GROUP BY e.id"
+ " ORDER BY e.start_time ASC")
public abstract DataSource.Factory<Integer, StatusEvent> getEventsWithStartTime(long minStartTime, long maxStartTime);
/**
* Returns events in progress at the specified time, ordered by descending start time.
*/
@Query("SELECT e.id, e.start_time, e.end_time, e.room_name, e.slug, et.title, et.subtitle, e.abstract, e.description"
+ ", GROUP_CONCAT(p.name, ', ') AS persons, e.day_index, d.date AS day_date, e.track_id, t.name AS track_name, t.type AS track_type"
+ ", b.event_id IS NOT NULL AS is_bookmarked"
+ " FROM events e"
+ " JOIN events_titles et ON e.id = et.`rowid`"
+ " JOIN days d ON e.day_index = d.`index`"
+ " JOIN tracks t ON e.track_id = t.id"
+ " LEFT JOIN events_persons ep ON e.id = ep.event_id"
+ " LEFT JOIN persons p ON ep.person_id = p.`rowid`"
+ " LEFT JOIN bookmarks b ON e.id = b.event_id"
+ " WHERE e.start_time <= :time AND :time < e.end_time"
+ " GROUP BY e.id"
+ " ORDER BY e.start_time DESC")
public abstract DataSource.Factory<Integer, StatusEvent> getEventsInProgress(long time);
/**
* Returns the events presented by the specified person.
*/
@Query("SELECT e.id , e.start_time, e.end_time, e.room_name, e.slug, et.title, et.subtitle, e.abstract, e.description"
+ ", GROUP_CONCAT(p.name, ', ') AS persons, e.day_index, d.date AS day_date, e.track_id, t.name AS track_name, t.type AS track_type"
+ ", b.event_id IS NOT NULL AS is_bookmarked"
+ " FROM events e"
+ " JOIN events_titles et ON e.id = et.`rowid`"
+ " JOIN days d ON e.day_index = d.`index`"
+ " JOIN tracks t ON e.track_id = t.id"
+ " LEFT JOIN events_persons ep ON e.id = ep.event_id"
+ " LEFT JOIN persons p ON ep.person_id = p.`rowid`"
+ " LEFT JOIN bookmarks b ON e.id = b.event_id"
+ " JOIN events_persons ep2 ON e.id = ep2.event_id"
+ " WHERE ep2.person_id = :person"
+ " GROUP BY e.id"
+ " ORDER BY e.start_time ASC")
public abstract DataSource.Factory<Integer, StatusEvent> getEvents(Person person);
/**
* Search through matching titles, subtitles, track names, person names. We need to use an union of 3 sub-queries because a "match" condition can not be
* accompanied by other conditions in a "where" statement.
*/
@Query("SELECT e.id, e.start_time, e.end_time, e.room_name, e.slug, et.title, et.subtitle, e.abstract, e.description"
+ ", GROUP_CONCAT(p.name, ', ') AS persons, e.day_index, d.date AS day_date, e.track_id, t.name AS track_name, t.type AS track_type"
+ ", b.event_id IS NOT NULL AS is_bookmarked"
+ " FROM events e"
+ " JOIN events_titles et ON e.id = et.`rowid`"
+ " JOIN days d ON e.day_index = d.`index`"
+ " JOIN tracks t ON e.track_id = t.id"
+ " LEFT JOIN events_persons ep ON e.id = ep.event_id"
+ " LEFT JOIN persons p ON ep.person_id = p.`rowid`"
+ " LEFT JOIN bookmarks b ON e.id = b.event_id"
+ " WHERE e.id IN ( "
+ "SELECT `rowid`"
+ " FROM events_titles"
+ " WHERE events_titles MATCH :query || '*'"
+ " UNION "
+ "SELECT e.id"
+ " FROM events e"
+ " JOIN tracks t ON e.track_id = t.id"
+ " WHERE t.name LIKE '%' || :query || '%'"
+ " UNION "
+ "SELECT ep.event_id"
+ " FROM events_persons ep"
+ " JOIN persons p ON ep.person_id = p.`rowid`"
+ " WHERE p.name MATCH :query || '*'"
+ " )"
+ " GROUP BY e.id"
+ " ORDER BY e.start_time ASC")
public abstract DataSource.Factory<Integer, StatusEvent> getSearchResults(String query);
/**
* Method called by SearchSuggestionProvider to return search results in the format expected by the search framework.
*/
@Query("SELECT e.id AS " + BaseColumns._ID
+ ", et.title AS " + SearchManager.SUGGEST_COLUMN_TEXT_1
+ ", IFNULL(GROUP_CONCAT(p.name, ', '), '') || ' - ' || t.name AS " + SearchManager.SUGGEST_COLUMN_TEXT_2
+ ", e.id AS " + SearchManager.SUGGEST_COLUMN_INTENT_DATA
+ " FROM events e"
+ " JOIN events_titles et ON e.id = et.`rowid`"
+ " JOIN tracks t ON e.track_id = t.id"
+ " LEFT JOIN events_persons ep ON e.id = ep.event_id"
+ " LEFT JOIN persons p ON ep.person_id = p.`rowid`"
+ " WHERE e.id IN ( "
+ "SELECT `rowid`"
+ " FROM events_titles"
+ " WHERE events_titles MATCH :query || '*'"
+ " UNION "
+ "SELECT e.id"
+ " FROM events e"
+ " JOIN tracks t ON e.track_id = t.id"
+ " WHERE t.name LIKE '%' || :query || '%'"
+ " UNION "
+ "SELECT ep.event_id"
+ " FROM events_persons ep"
+ " JOIN persons p ON ep.person_id = p.`rowid`"
+ " WHERE p.name MATCH :query || '*'"
+ " )"
+ " GROUP BY e.id"
+ " ORDER BY e.start_time ASC LIMIT :limit")
@WorkerThread
public abstract Cursor getSearchSuggestionResults(String query, int limit);
/**
* Returns all persons in alphabetical order.
*/
@Query("SELECT `rowid`, name"
+ " FROM persons"
+ " ORDER BY name COLLATE NOCASE")
public abstract DataSource.Factory<Integer, Person> getPersons();
public LiveData<EventDetails> getEventDetails(final Event event) {
final MutableLiveData<EventDetails> result = new MutableLiveData<>();
appDatabase.getQueryExecutor().execute(() -> result.postValue(new EventDetails(getPersons(event), getLinks(event))));
return result;
}
@Query("SELECT p.`rowid`, p.name"
+ " FROM persons p"
+ " JOIN events_persons ep ON p.`rowid` = ep.person_id"
+ " WHERE ep.event_id = :event")
protected abstract List<Person> getPersons(Event event);
@Query("SELECT * FROM links WHERE event_id = :event ORDER BY id ASC")
protected abstract List<Link> getLinks(Event event);
}

View file

@ -0,0 +1,467 @@
package be.digitalia.fosdem.db
import android.app.SearchManager
import android.database.Cursor
import android.provider.BaseColumns
import androidx.annotation.MainThread
import androidx.annotation.WorkerThread
import androidx.core.content.edit
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.liveData
import androidx.paging.DataSource
import androidx.room.*
import be.digitalia.fosdem.db.entities.EventEntity
import be.digitalia.fosdem.db.entities.EventTitles
import be.digitalia.fosdem.db.entities.EventToPerson
import be.digitalia.fosdem.model.*
import be.digitalia.fosdem.utils.DateUtils
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import java.util.*
@Dao
abstract class ScheduleDao(private val appDatabase: AppDatabase) {
private val _latestUpdateTime = MutableLiveData<Long>()
/**
* @return The last update time in milliseconds since EPOCH, or -1 if not available.
* This LiveData is pre-initialized with the up-to-date value.
*/
val latestUpdateTime: LiveData<Long>
@MainThread
get() {
if (_latestUpdateTime.value == null) {
_latestUpdateTime.value = appDatabase.sharedPreferences.getLong(LAST_UPDATE_TIME_PREF, -1L)
}
return _latestUpdateTime
}
/**
* @return The time identifier of the current version of the database.
*/
val lastModifiedTag: String?
get() = appDatabase.sharedPreferences.getString(LAST_MODIFIED_TAG_PREF, null)
private class EmptyScheduleException : Exception()
/**
* Stores the schedule in the database.
*
* @param events The events stream.
* @return The number of events processed.
*/
@WorkerThread
fun storeSchedule(events: Sequence<DetailedEvent>, lastModifiedTag: String?): Int {
val totalEvents = try {
storeScheduleInternal(events, lastModifiedTag)
} catch (ese: EmptyScheduleException) {
0
}
if (totalEvents > 0) { // Set last update time and server's last modified tag
val now = System.currentTimeMillis()
appDatabase.sharedPreferences.edit {
putLong(LAST_UPDATE_TIME_PREF, now)
putString(LAST_MODIFIED_TAG_PREF, lastModifiedTag)
}
_latestUpdateTime.postValue(now)
}
return totalEvents
}
@Transaction
protected open fun storeScheduleInternal(events: Sequence<DetailedEvent>, lastModifiedTag: String?): Int {
// 1: Delete the previous schedule
clearSchedule()
// 2: Insert the events
var totalEvents = 0
val tracks = mutableMapOf<Track, Long>()
var nextTrackId = 0L
var minEventId = Long.MAX_VALUE
val days: MutableSet<Day> = HashSet(2)
for ((event, details) in events) {
// Retrieve or insert Track
val track = event.track
var trackId = tracks[track]
if (trackId == null) {
// New track
trackId = ++nextTrackId
val newTrack = Track(trackId, track.name, track.type)
insertTrack(newTrack)
tracks[newTrack] = trackId
}
val eventId = event.id
try {
// Insert main event and fulltext fields
val eventEntity = EventEntity(
eventId,
event.day.index,
event.startTime,
event.endTime,
event.roomName,
event.slug,
trackId,
event.abstractText,
event.description
)
val eventTitles = EventTitles(
eventId,
event.title,
event.subTitle
)
insertEvent(eventEntity, eventTitles)
} catch (e: Exception) {
// Duplicate event: skip
continue
}
days.add(event.day)
if (eventId < minEventId) {
minEventId = eventId
}
val persons = details.persons
insertPersons(persons)
val eventsToPersons = Array(persons.size) {
EventToPerson(eventId, persons[it].id)
}
insertEventsToPersons(eventsToPersons)
insertLinks(details.links)
totalEvents++
}
if (totalEvents == 0) {
// Rollback the transaction
throw EmptyScheduleException()
}
// 3: Insert collected days
insertDays(days)
// 4: Purge outdated bookmarks
purgeOutdatedBookmarks(minEventId)
return totalEvents
}
@Insert
protected abstract fun insertTrack(track: Track)
@Insert
protected abstract fun insertEvent(eventEntity: EventEntity, eventTitles: EventTitles)
@Insert(onConflict = OnConflictStrategy.IGNORE)
protected abstract fun insertPersons(persons: List<Person>)
@Insert
protected abstract fun insertEventsToPersons(eventsToPersons: Array<EventToPerson>)
@Insert
protected abstract fun insertLinks(links: List<Link>)
@Insert
protected abstract fun insertDays(days: Set<Day>)
@Query("DELETE FROM bookmarks WHERE event_id < :minEventId")
protected abstract fun purgeOutdatedBookmarks(minEventId: Long)
@WorkerThread
@Transaction
open fun clearSchedule() {
clearEvents()
clearEventTitles()
clearPersons()
clearEventToPersons()
clearLinks()
clearTracks()
clearDays()
}
@Query("DELETE FROM events")
protected abstract fun clearEvents()
@Query("DELETE FROM events_titles")
protected abstract fun clearEventTitles()
@Query("DELETE FROM persons")
protected abstract fun clearPersons()
@Query("DELETE FROM events_persons")
protected abstract fun clearEventToPersons()
@Query("DELETE FROM links")
protected abstract fun clearLinks()
@Query("DELETE FROM tracks")
protected abstract fun clearTracks()
@Query("DELETE FROM days")
protected abstract fun clearDays()
// Cache days
private val daysLiveDataDelegate = lazy { getDaysInternal() }
val days: LiveData<List<Day>> by daysLiveDataDelegate
@Query("SELECT `index`, date FROM days ORDER BY `index` ASC")
protected abstract fun getDaysInternal(): LiveData<List<Day>>
@WorkerThread
fun getYear(): Int {
var date = 0L
// Compute from cached days if available
val days = if (daysLiveDataDelegate.isInitialized()) days.value else null
if (days != null) {
if (days.isNotEmpty()) {
date = days[0].date.time
}
} else {
date = getConferenceStartDate()
}
// Use the current year by default
if (date == 0L) {
date = System.currentTimeMillis()
}
return DateUtils.getYear(date)
}
@Query("SELECT date FROM days ORDER BY `index` ASC LIMIT 1")
protected abstract fun getConferenceStartDate(): Long
@Query("""SELECT t.id, t.name, t.type FROM tracks t
JOIN events e ON t.id = e.track_id
WHERE e.day_index = :day
GROUP BY t.id
ORDER BY t.name ASC""")
abstract fun getTracks(day: Day): LiveData<List<Track>>
/**
* Returns the event with the specified id, or null if not found.
*/
@Query("""SELECT e.id, e.start_time, e.end_time, e.room_name, e.slug, et.title, et.subtitle, e.abstract, e.description,
GROUP_CONCAT(p.name, ', ') AS persons, e.day_index, d.date AS day_date, e.track_id, t.name AS track_name, t.type AS track_type
FROM events e
JOIN events_titles et ON e.id = et.`rowid`
JOIN days d ON e.day_index = d.`index`
JOIN tracks t ON e.track_id = t.id
LEFT JOIN events_persons ep ON e.id = ep.event_id
LEFT JOIN persons p ON ep.person_id = p.`rowid`
WHERE e.id = :id
GROUP BY e.id""")
@WorkerThread
abstract fun getEvent(id: Long): Event?
/**
* Returns all found events whose id is part of the given list.
*/
@Query("""SELECT e.id, e.start_time, e.end_time, e.room_name, e.slug, et.title, et.subtitle, e.abstract, e.description,
GROUP_CONCAT(p.name, ', ') AS persons, e.day_index, d.date AS day_date, e.track_id, t.name AS track_name, t.type AS track_type,
b.event_id IS NOT NULL AS is_bookmarked
FROM events e
JOIN events_titles et ON e.id = et.`rowid`
JOIN days d ON e.day_index = d.`index`
JOIN tracks t ON e.track_id = t.id
LEFT JOIN events_persons ep ON e.id = ep.event_id
LEFT JOIN persons p ON ep.person_id = p.`rowid`
LEFT JOIN bookmarks b ON e.id = b.event_id
WHERE e.id IN (:ids)
GROUP BY e.id
ORDER BY e.start_time ASC""")
abstract fun getEvents(ids: LongArray): DataSource.Factory<Int, StatusEvent>
/**
* Returns the events for a specified track.
*/
@Query("""SELECT e.id, e.start_time, e.end_time, e.room_name, e.slug, et.title, et.subtitle, e.abstract, e.description,
GROUP_CONCAT(p.name, ', ') AS persons, e.day_index, d.date AS day_date, e.track_id, t.name AS track_name, t.type AS track_type,
b.event_id IS NOT NULL AS is_bookmarked
FROM events e
JOIN events_titles et ON e.id = et.`rowid`
JOIN days d ON e.day_index = d.`index`
JOIN tracks t ON e.track_id = t.id
LEFT JOIN events_persons ep ON e.id = ep.event_id
LEFT JOIN persons p ON ep.person_id = p.`rowid`
LEFT JOIN bookmarks b ON e.id = b.event_id
WHERE e.day_index = :day AND e.track_id = :track
GROUP BY e.id
ORDER BY e.start_time ASC""")
abstract fun getEvents(day: Day, track: Track): LiveData<List<StatusEvent>>
/**
* Returns a snapshot of the events for a specified track (without the bookmark status).
*/
@Query("""SELECT e.id, e.start_time, e.end_time, e.room_name, e.slug, et.title, et.subtitle, e.abstract, e.description,
GROUP_CONCAT(p.name, ', ') AS persons, e.day_index, d.date AS day_date, e.track_id, t.name AS track_name, t.type AS track_type
FROM events e
JOIN events_titles et ON e.id = et.`rowid`
JOIN days d ON e.day_index = d.`index`
JOIN tracks t ON e.track_id = t.id
LEFT JOIN events_persons ep ON e.id = ep.event_id
LEFT JOIN persons p ON ep.person_id = p.`rowid`
WHERE e.day_index = :day AND e.track_id = :track
GROUP BY e.id
ORDER BY e.start_time ASC""")
abstract fun getEventsSnapshot(day: Day, track: Track): List<Event>
/**
* Returns events starting in the specified interval, ordered by ascending start time.
*/
@Query("""SELECT e.id, e.start_time, e.end_time, e.room_name, e.slug, et.title, et.subtitle, e.abstract, e.description,
GROUP_CONCAT(p.name, ', ') AS persons, e.day_index, d.date AS day_date, e.track_id, t.name AS track_name, t.type AS track_type,
b.event_id IS NOT NULL AS is_bookmarked
FROM events e
JOIN events_titles et ON e.id = et.`rowid`
JOIN days d ON e.day_index = d.`index`
JOIN tracks t ON e.track_id = t.id
LEFT JOIN events_persons ep ON e.id = ep.event_id
LEFT JOIN persons p ON ep.person_id = p.`rowid`
LEFT JOIN bookmarks b ON e.id = b.event_id
WHERE e.start_time BETWEEN :minStartTime AND :maxStartTime
GROUP BY e.id
ORDER BY e.start_time ASC""")
abstract fun getEventsWithStartTime(minStartTime: Long, maxStartTime: Long): DataSource.Factory<Int, StatusEvent>
/**
* Returns events in progress at the specified time, ordered by descending start time.
*/
@Query("""SELECT e.id, e.start_time, e.end_time, e.room_name, e.slug, et.title, et.subtitle, e.abstract, e.description,
GROUP_CONCAT(p.name, ', ') AS persons, e.day_index, d.date AS day_date, e.track_id, t.name AS track_name, t.type AS track_type,
b.event_id IS NOT NULL AS is_bookmarked
FROM events e
JOIN events_titles et ON e.id = et.`rowid`
JOIN days d ON e.day_index = d.`index`
JOIN tracks t ON e.track_id = t.id
LEFT JOIN events_persons ep ON e.id = ep.event_id
LEFT JOIN persons p ON ep.person_id = p.`rowid`
LEFT JOIN bookmarks b ON e.id = b.event_id
WHERE e.start_time <= :time AND :time < e.end_time
GROUP BY e.id
ORDER BY e.start_time DESC""")
abstract fun getEventsInProgress(time: Long): DataSource.Factory<Int, StatusEvent>
/**
* Returns the events presented by the specified person.
*/
@Query("""SELECT e.id , e.start_time, e.end_time, e.room_name, e.slug, et.title, et.subtitle, e.abstract, e.description,
GROUP_CONCAT(p.name, ', ') AS persons, e.day_index, d.date AS day_date, e.track_id, t.name AS track_name, t.type AS track_type,
b.event_id IS NOT NULL AS is_bookmarked
FROM events e JOIN events_titles et ON e.id = et.`rowid`
JOIN days d ON e.day_index = d.`index`
JOIN tracks t ON e.track_id = t.id
LEFT JOIN events_persons ep ON e.id = ep.event_id
LEFT JOIN persons p ON ep.person_id = p.`rowid`
LEFT JOIN bookmarks b ON e.id = b.event_id
JOIN events_persons ep2 ON e.id = ep2.event_id
WHERE ep2.person_id = :person
GROUP BY e.id
ORDER BY e.start_time ASC""")
abstract fun getEvents(person: Person): DataSource.Factory<Int, StatusEvent>
/**
* Search through matching titles, subtitles, track names, person names.
* We need to use an union of 3 sub-queries because a "match" condition can not be
* accompanied by other conditions in a "where" statement.
*/
@Query("""SELECT e.id, e.start_time, e.end_time, e.room_name, e.slug, et.title, et.subtitle, e.abstract, e.description,
GROUP_CONCAT(p.name, ', ') AS persons, e.day_index, d.date AS day_date, e.track_id, t.name AS track_name, t.type AS track_type,
b.event_id IS NOT NULL AS is_bookmarked
FROM events e
JOIN events_titles et ON e.id = et.`rowid`
JOIN days d ON e.day_index = d.`index`
JOIN tracks t ON e.track_id = t.id
LEFT JOIN events_persons ep ON e.id = ep.event_id
LEFT JOIN persons p ON ep.person_id = p.`rowid`
LEFT JOIN bookmarks b ON e.id = b.event_id
WHERE e.id IN (
SELECT `rowid`
FROM events_titles
WHERE events_titles MATCH :query || '*'
UNION
SELECT e.id
FROM events e
JOIN tracks t ON e.track_id = t.id
WHERE t.name LIKE '%' || :query || '%'
UNION
SELECT ep.event_id
FROM events_persons ep
JOIN persons p ON ep.person_id = p.`rowid`
WHERE p.name MATCH :query || '*'
)
GROUP BY e.id
ORDER BY e.start_time ASC""")
abstract fun getSearchResults(query: String): DataSource.Factory<Int, StatusEvent>
/**
* Method called by SearchSuggestionProvider to return search results in the format expected by the search framework.
*/
@Query("""SELECT e.id AS ${BaseColumns._ID},
et.title AS ${SearchManager.SUGGEST_COLUMN_TEXT_1},
IFNULL(GROUP_CONCAT(p.name, ', '), '') || ' - ' || t.name AS ${SearchManager.SUGGEST_COLUMN_TEXT_2},
e.id AS ${SearchManager.SUGGEST_COLUMN_INTENT_DATA}
FROM events e
JOIN events_titles et ON e.id = et.`rowid`
JOIN tracks t ON e.track_id = t.id
LEFT JOIN events_persons ep ON e.id = ep.event_id
LEFT JOIN persons p ON ep.person_id = p.`rowid`
WHERE e.id IN (
SELECT `rowid`
FROM events_titles
WHERE events_titles MATCH :query || '*'
UNION
SELECT e.id
FROM events e
JOIN tracks t ON e.track_id = t.id
WHERE t.name LIKE '%' || :query || '%'
UNION
SELECT ep.event_id
FROM events_persons ep
JOIN persons p ON ep.person_id = p.`rowid`
WHERE p.name MATCH :query || '*'
)
GROUP BY e.id
ORDER BY e.start_time ASC LIMIT :limit""")
@WorkerThread
abstract fun getSearchSuggestionResults(query: String, limit: Int): Cursor
/**
* Returns all persons in alphabetical order.
*/
@Query("""SELECT `rowid`, name
FROM persons
ORDER BY name COLLATE NOCASE""")
abstract fun getPersons(): DataSource.Factory<Int, Person>
fun getEventDetails(event: Event): LiveData<EventDetails> {
return liveData {
// Load persons and links in parallel as soon as the LiveData becomes active
coroutineScope {
val persons = async { getPersons(event) }
val links = async { getLinks(event) }
emit(EventDetails(persons.await(), links.await()))
}
}
}
@Query("""SELECT p.`rowid`, p.name
FROM persons p
JOIN events_persons ep ON p.`rowid` = ep.person_id
WHERE ep.event_id = :event""")
protected abstract suspend fun getPersons(event: Event): List<Person>
@Query("SELECT * FROM links WHERE event_id = :event ORDER BY id ASC")
protected abstract suspend fun getLinks(event: Event?): List<Link>
companion object {
private const val LAST_UPDATE_TIME_PREF = "last_update_time"
private const val LAST_MODIFIED_TAG_PREF = "last_modified_tag"
}
}

View file

@ -1,39 +0,0 @@
package be.digitalia.fosdem.db.converters;
import androidx.room.TypeConverter;
import be.digitalia.fosdem.model.Day;
import be.digitalia.fosdem.model.Event;
import be.digitalia.fosdem.model.Person;
import be.digitalia.fosdem.model.Track;
public class GlobalTypeConverters {
@TypeConverter
public static Track.Type toTrackType(String value) {
return Track.Type.valueOf(value);
}
@TypeConverter
public static String fromTrackType(Track.Type value) {
return value.name();
}
@TypeConverter
public static long fromDay(Day day) {
return day.getIndex();
}
@TypeConverter
public static long fromTrack(Track track) {
return track.getId();
}
@TypeConverter
public static long fromPerson(Person person) {
return person.getId();
}
@TypeConverter
public static long fromEvent(Event event) {
return event.getId();
}
}

View file

@ -0,0 +1,33 @@
package be.digitalia.fosdem.db.converters
import androidx.room.TypeConverter
import be.digitalia.fosdem.model.Day
import be.digitalia.fosdem.model.Event
import be.digitalia.fosdem.model.Person
import be.digitalia.fosdem.model.Track
object GlobalTypeConverters {
@JvmStatic
@TypeConverter
fun toTrackType(value: String): Track.Type = enumValueOf(value)
@JvmStatic
@TypeConverter
fun fromTrackType(value: Track.Type): String = value.name
@JvmStatic
@TypeConverter
fun fromDay(day: Day): Long = day.index.toLong()
@JvmStatic
@TypeConverter
fun fromTrack(track: Track): Long = track.id
@JvmStatic
@TypeConverter
fun fromPerson(person: Person): Long = person.id
@JvmStatic
@TypeConverter
fun fromEvent(event: Event): Long = event.id
}

View file

@ -1,18 +0,0 @@
package be.digitalia.fosdem.db.converters;
import java.util.Date;
import androidx.annotation.NonNull;
import androidx.room.TypeConverter;
public class NonNullDateTypeConverters {
@TypeConverter
public static Date toDate(long value) {
return new Date(value);
}
@TypeConverter
public static long fromDate(@NonNull Date value) {
return value.getTime();
}
}

View file

@ -0,0 +1,14 @@
package be.digitalia.fosdem.db.converters
import androidx.room.TypeConverter
import java.util.*
object NonNullDateTypeConverters {
@JvmStatic
@TypeConverter
fun toDate(value: Long): Date = Date(value)
@JvmStatic
@TypeConverter
fun fromDate(value: Date): Long = value.time
}

View file

@ -1,17 +0,0 @@
package be.digitalia.fosdem.db.converters;
import java.util.Date;
import androidx.room.TypeConverter;
public class NullableDateTypeConverters {
@TypeConverter
public static Date toDate(Long value) {
return value == null ? null : new Date(value);
}
@TypeConverter
public static Long fromDate(Date value) {
return (value == null) ? null : value.getTime();
}
}

View file

@ -0,0 +1,14 @@
package be.digitalia.fosdem.db.converters
import androidx.room.TypeConverter
import java.util.*
object NullableDateTypeConverters {
@JvmStatic
@TypeConverter
fun toDate(value: Long?): Date? = value?.let { Date(it) }
@JvmStatic
@TypeConverter
fun fromDate(value: Date?): Long? = value?.time
}

View file

@ -1,23 +0,0 @@
package be.digitalia.fosdem.db.entities;
import androidx.room.ColumnInfo;
import androidx.room.Entity;
import androidx.room.PrimaryKey;
@Entity(tableName = Bookmark.TABLE_NAME)
public class Bookmark {
public static final String TABLE_NAME = "bookmarks";
@PrimaryKey
@ColumnInfo(name = "event_id")
private final long eventId;
public Bookmark(long eventId) {
this.eventId = eventId;
}
public long getEventId() {
return eventId;
}
}

View file

@ -0,0 +1,16 @@
package be.digitalia.fosdem.db.entities
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = Bookmark.TABLE_NAME)
class Bookmark(
@PrimaryKey
@ColumnInfo(name = "event_id")
val eventId: Long
) {
companion object {
const val TABLE_NAME = "bookmarks"
}
}

View file

@ -1,95 +0,0 @@
package be.digitalia.fosdem.db.entities;
import java.util.Date;
import androidx.room.ColumnInfo;
import androidx.room.Entity;
import androidx.room.Index;
import androidx.room.PrimaryKey;
import androidx.room.TypeConverters;
import be.digitalia.fosdem.db.converters.NullableDateTypeConverters;
import be.digitalia.fosdem.model.Event;
@Entity(tableName = EventEntity.TABLE_NAME, indices = {
@Index(value = {"day_index"}, name = "event_day_index_idx"),
@Index(value = {"start_time"}, name = "event_start_time_idx"),
@Index(value = {"end_time"}, name = "event_end_time_idx"),
@Index(value = {"track_id"}, name = "event_track_id_idx")
})
public class EventEntity {
public static final String TABLE_NAME = "events";
@PrimaryKey
private final long id;
@ColumnInfo(name = "day_index")
private final int dayIndex;
@ColumnInfo(name = "start_time")
@TypeConverters({NullableDateTypeConverters.class})
private final Date startTime;
@ColumnInfo(name = "end_time")
@TypeConverters({NullableDateTypeConverters.class})
private final Date endTime;
@ColumnInfo(name = "room_name")
private final String roomName;
private final String slug;
@ColumnInfo(name = "track_id")
private final long trackId;
@ColumnInfo(name = "abstract")
private final String abstractText;
private final String description;
public EventEntity(Event event) {
this(event.getId(), event.getDay().getIndex(), event.getStartTime(), event.getEndTime(), event.getRoomName(),
event.getSlug(), event.getTrack().getId(), event.getAbstractText(), event.getDescription());
}
public EventEntity(long id, int dayIndex, Date startTime, Date endTime, String roomName,
String slug, long trackId, String abstractText, String description) {
this.id = id;
this.dayIndex = dayIndex;
this.startTime = startTime;
this.endTime = endTime;
this.roomName = roomName;
this.slug = slug;
this.trackId = trackId;
this.abstractText = abstractText;
this.description = description;
}
public long getId() {
return id;
}
public int getDayIndex() {
return dayIndex;
}
public Date getStartTime() {
return startTime;
}
public Date getEndTime() {
return endTime;
}
public String getRoomName() {
return roomName;
}
public String getSlug() {
return slug;
}
public long getTrackId() {
return trackId;
}
public String getAbstractText() {
return abstractText;
}
public String getDescription() {
return description;
}
}

View file

@ -0,0 +1,36 @@
package be.digitalia.fosdem.db.entities
import androidx.room.*
import be.digitalia.fosdem.db.converters.NullableDateTypeConverters
import java.util.*
@Entity(tableName = EventEntity.TABLE_NAME, indices = [
Index(value = ["day_index"], name = "event_day_index_idx"),
Index(value = ["start_time"], name = "event_start_time_idx"),
Index(value = ["end_time"], name = "event_end_time_idx"),
Index(value = ["track_id"], name = "event_track_id_idx")
])
class EventEntity(
@PrimaryKey
val id: Long,
@ColumnInfo(name = "day_index")
val dayIndex: Int,
@ColumnInfo(name = "start_time")
@field:TypeConverters(NullableDateTypeConverters::class)
val startTime: Date?,
@ColumnInfo(name = "end_time")
@field:TypeConverters(NullableDateTypeConverters::class)
val endTime: Date?,
@ColumnInfo(name = "room_name")
val roomName: String?,
val slug: String?,
@ColumnInfo(name = "track_id")
val trackId: Long,
@ColumnInfo(name = "abstract")
val abstractText: String?,
val description: String?
) {
companion object {
const val TABLE_NAME = "events"
}
}

View file

@ -1,43 +0,0 @@
package be.digitalia.fosdem.db.entities;
import androidx.room.ColumnInfo;
import androidx.room.Entity;
import androidx.room.Fts3;
import androidx.room.PrimaryKey;
import be.digitalia.fosdem.model.Event;
@Fts3
@Entity(tableName = EventTitles.TABLE_NAME)
public class EventTitles {
public static final String TABLE_NAME = "events_titles";
@PrimaryKey
@ColumnInfo(name = "rowid")
private final long id;
private final String title;
@ColumnInfo(name = "subtitle")
private final String subTitle;
public EventTitles(Event event) {
this(event.getId(), event.getTitle(), event.getSubTitle());
}
public EventTitles(long id, String title, String subTitle) {
this.id = id;
this.title = title;
this.subTitle = subTitle;
}
public long getId() {
return id;
}
public String getTitle() {
return title;
}
public String getSubTitle() {
return subTitle;
}
}

View file

@ -0,0 +1,21 @@
package be.digitalia.fosdem.db.entities
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Fts3
import androidx.room.PrimaryKey
@Fts3
@Entity(tableName = EventTitles.TABLE_NAME)
class EventTitles(
@PrimaryKey
@ColumnInfo(name = "rowid")
val id: Long,
val title: String?,
@ColumnInfo(name = "subtitle")
val subTitle: String?
) {
companion object {
const val TABLE_NAME = "events_titles"
}
}

View file

@ -1,30 +0,0 @@
package be.digitalia.fosdem.db.entities;
import androidx.room.ColumnInfo;
import androidx.room.Entity;
import androidx.room.Index;
@Entity(tableName = EventToPerson.TABLE_NAME, primaryKeys = {"event_id", "person_id"},
indices = {@Index(value = {"person_id"}, name = "event_person_person_id_idx")})
public class EventToPerson {
public static final String TABLE_NAME = "events_persons";
@ColumnInfo(name = "event_id")
private final long eventId;
@ColumnInfo(name = "person_id")
private final long personId;
public EventToPerson(long eventId, long personId) {
this.eventId = eventId;
this.personId = personId;
}
public long getEventId() {
return eventId;
}
public long getPersonId() {
return personId;
}
}

View file

@ -0,0 +1,18 @@
package be.digitalia.fosdem.db.entities
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Index
@Entity(tableName = EventToPerson.TABLE_NAME, primaryKeys = ["event_id", "person_id"],
indices = [Index(value = ["person_id"], name = "event_person_person_id_idx")])
class EventToPerson(
@ColumnInfo(name = "event_id")
val eventId: Long,
@ColumnInfo(name = "person_id")
val personId: Long
) {
companion object {
const val TABLE_NAME = "events_persons"
}
}

View file

@ -1,71 +0,0 @@
package be.digitalia.fosdem.fragments;
import android.os.Bundle;
import androidx.annotation.NonNull;
import androidx.fragment.app.Fragment;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.Observer;
import androidx.lifecycle.ViewModelProvider;
import androidx.paging.PagedList;
import androidx.recyclerview.widget.DividerItemDecoration;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import be.digitalia.fosdem.adapters.EventsAdapter;
import be.digitalia.fosdem.model.StatusEvent;
import be.digitalia.fosdem.viewmodels.LiveViewModel;
public abstract class BaseLiveListFragment extends RecyclerViewFragment implements Observer<PagedList<StatusEvent>> {
private EventsAdapter adapter;
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
adapter = new EventsAdapter(getContext(), this, false);
}
@Override
protected void onRecyclerViewCreated(RecyclerView recyclerView, Bundle savedInstanceState) {
Fragment parentFragment = getParentFragment();
if (parentFragment instanceof RecycledViewPoolProvider) {
recyclerView.setRecycledViewPool(((RecycledViewPoolProvider) parentFragment).getRecycledViewPool());
}
recyclerView.setLayoutManager(new LinearLayoutManager(recyclerView.getContext()));
recyclerView.addItemDecoration(new DividerItemDecoration(recyclerView.getContext(), DividerItemDecoration.VERTICAL));
}
@Override
public void onActivityCreated(Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
setAdapter(adapter);
setEmptyText(getEmptyText());
setProgressBarVisible(true);
final LiveViewModel viewModel = new ViewModelProvider(requireParentFragment()).get(LiveViewModel.class);
getDataSource(viewModel).observe(getViewLifecycleOwner(), this);
}
private final Runnable preserveScrollPositionRunnable = () -> {
// Ensure we stay at scroll position 0 so we can see the insertion animation
final RecyclerView recyclerView = getRecyclerView();
if (recyclerView != null) {
if (recyclerView.getScrollY() == 0) {
recyclerView.scrollToPosition(0);
}
}
};
@Override
public void onChanged(PagedList<StatusEvent> events) {
adapter.submitList(events, preserveScrollPositionRunnable);
setProgressBarVisible(false);
}
protected abstract String getEmptyText();
@NonNull
protected abstract LiveData<PagedList<StatusEvent>> getDataSource(@NonNull LiveViewModel viewModel);
}

View file

@ -1,175 +0,0 @@
package be.digitalia.fosdem.fragments;
import android.content.Context;
import android.content.Intent;
import android.nfc.NdefRecord;
import android.os.Bundle;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import java.util.List;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.view.ActionMode;
import androidx.lifecycle.Observer;
import androidx.lifecycle.ViewModelProvider;
import androidx.recyclerview.widget.DividerItemDecoration;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import be.digitalia.fosdem.R;
import be.digitalia.fosdem.adapters.BookmarksAdapter;
import be.digitalia.fosdem.model.Event;
import be.digitalia.fosdem.providers.BookmarksExportProvider;
import be.digitalia.fosdem.utils.NfcUtils;
import be.digitalia.fosdem.viewmodels.BookmarksViewModel;
import be.digitalia.fosdem.widgets.MultiChoiceHelper;
/**
* Bookmarks list, optionally filterable.
*
* @author Christophe Beyls
*/
public class BookmarksListFragment extends RecyclerViewFragment
implements Observer<List<Event>>, NfcUtils.CreateNfcAppDataCallback {
private static final String PREF_UPCOMING_ONLY = "bookmarks_upcoming_only";
private BookmarksViewModel viewModel;
private BookmarksAdapter adapter;
private MenuItem filterMenuItem;
private MenuItem upcomingOnlyMenuItem;
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
viewModel = new ViewModelProvider(this).get(BookmarksViewModel.class);
final MultiChoiceHelper.MultiChoiceModeListener multiChoiceModeListener = new MultiChoiceHelper.MultiChoiceModeListener() {
@Override
public boolean onCreateActionMode(ActionMode mode, Menu menu) {
mode.getMenuInflater().inflate(R.menu.action_mode_bookmarks, menu);
return true;
}
private void updateSelectedCountDisplay(ActionMode mode) {
int count = adapter.getMultiChoiceHelper().getCheckedItemCount();
mode.setTitle(getResources().getQuantityString(R.plurals.selected, count, count));
}
@Override
public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
updateSelectedCountDisplay(mode);
return true;
}
@Override
public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
switch (item.getItemId()) {
case R.id.delete:
// Remove multiple bookmarks at once
viewModel.removeBookmarks(adapter.getMultiChoiceHelper().getCheckedItemIds());
mode.finish();
return true;
}
return false;
}
@Override
public void onItemCheckedStateChanged(ActionMode mode, int position, long id, boolean checked) {
updateSelectedCountDisplay(mode);
}
@Override
public void onDestroyActionMode(ActionMode mode) {
}
};
adapter = new BookmarksAdapter((AppCompatActivity) requireActivity(), this, multiChoiceModeListener);
boolean upcomingOnly = requireActivity().getPreferences(Context.MODE_PRIVATE).getBoolean(PREF_UPCOMING_ONLY, false);
viewModel.setUpcomingOnly(upcomingOnly);
setHasOptionsMenu(true);
}
@Override
protected void onRecyclerViewCreated(RecyclerView recyclerView, Bundle savedInstanceState) {
recyclerView.setLayoutManager(new LinearLayoutManager(recyclerView.getContext()));
recyclerView.addItemDecoration(new DividerItemDecoration(recyclerView.getContext(), DividerItemDecoration.VERTICAL));
}
@Override
public void onActivityCreated(Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
setAdapter(adapter);
setEmptyText(getString(R.string.no_bookmark));
setProgressBarVisible(true);
viewModel.getBookmarks().observe(getViewLifecycleOwner(), this);
}
@Override
public void onCreateOptionsMenu(@NonNull Menu menu, MenuInflater inflater) {
inflater.inflate(R.menu.bookmarks, menu);
filterMenuItem = menu.findItem(R.id.filter);
upcomingOnlyMenuItem = menu.findItem(R.id.upcoming_only);
updateFilterMenuItem();
}
private void updateFilterMenuItem() {
if (filterMenuItem != null) {
final boolean upcomingOnly = viewModel.getUpcomingOnly();
filterMenuItem.setIcon(upcomingOnly ?
R.drawable.ic_filter_list_selected_white_24dp
: R.drawable.ic_filter_list_white_24dp);
upcomingOnlyMenuItem.setChecked(upcomingOnly);
}
}
@Override
public void onDestroyOptionsMenu() {
super.onDestroyOptionsMenu();
filterMenuItem = null;
upcomingOnlyMenuItem = null;
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
switch (item.getItemId()) {
case R.id.upcoming_only:
final boolean upcomingOnly = !viewModel.getUpcomingOnly();
viewModel.setUpcomingOnly(upcomingOnly);
updateFilterMenuItem();
requireActivity().getPreferences(Context.MODE_PRIVATE).edit()
.putBoolean(PREF_UPCOMING_ONLY, upcomingOnly)
.apply();
return true;
case R.id.export_bookmarks:
Intent exportIntent = BookmarksExportProvider.getIntent(getActivity());
startActivity(Intent.createChooser(exportIntent, getString(R.string.export_bookmarks)));
return true;
}
return false;
}
@Override
public void onChanged(List<Event> bookmarks) {
adapter.submitList(bookmarks);
setProgressBarVisible(false);
}
@Nullable
@Override
public NdefRecord createNfcAppData() {
Context context = getContext();
List<Event> bookmarks = (viewModel == null) ? null : viewModel.getBookmarks().getValue();
if (context == null || bookmarks == null || bookmarks.size() == 0) {
return null;
}
return NfcUtils.createBookmarksAppData(context, bookmarks);
}
}

View file

@ -0,0 +1,149 @@
package be.digitalia.fosdem.fragments
import android.content.Context
import android.content.Intent
import android.nfc.NdefRecord
import android.os.Bundle
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ActionMode
import androidx.core.content.edit
import androidx.fragment.app.viewModels
import androidx.lifecycle.observe
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import be.digitalia.fosdem.R
import be.digitalia.fosdem.adapters.BookmarksAdapter
import be.digitalia.fosdem.providers.BookmarksExportProvider
import be.digitalia.fosdem.utils.CreateNfcAppDataCallback
import be.digitalia.fosdem.utils.toBookmarksNfcAppData
import be.digitalia.fosdem.viewmodels.BookmarksViewModel
import be.digitalia.fosdem.widgets.MultiChoiceHelper
/**
* Bookmarks list, optionally filterable.
*
* @author Christophe Beyls
*/
class BookmarksListFragment : RecyclerViewFragment(), CreateNfcAppDataCallback {
private val viewModel: BookmarksViewModel by viewModels()
private val adapter: BookmarksAdapter by lazy(LazyThreadSafetyMode.NONE) {
val multiChoiceModeListener = object : MultiChoiceHelper.MultiChoiceModeListener {
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
mode.menuInflater.inflate(R.menu.action_mode_bookmarks, menu)
return true
}
private fun updateSelectedCountDisplay(mode: ActionMode) {
val count = adapter.multiChoiceHelper.checkedItemCount
mode.title = resources.getQuantityString(R.plurals.selected, count, count)
}
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
updateSelectedCountDisplay(mode)
return true
}
override fun onActionItemClicked(mode: ActionMode, item: MenuItem) = when (item.itemId) {
R.id.delete -> {
// Remove multiple bookmarks at once
viewModel.removeBookmarks(adapter.multiChoiceHelper.checkedItemIds)
mode.finish()
true
}
else -> false
}
override fun onItemCheckedStateChanged(mode: ActionMode, position: Int, id: Long, checked: Boolean) {
updateSelectedCountDisplay(mode)
}
override fun onDestroyActionMode(mode: ActionMode) {}
}
BookmarksAdapter((requireActivity() as AppCompatActivity), this, multiChoiceModeListener)
}
private var filterMenuItem: MenuItem? = null
private var upcomingOnlyMenuItem: MenuItem? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val upcomingOnly = requireActivity().getPreferences(Context.MODE_PRIVATE).getBoolean(PREF_UPCOMING_ONLY, false)
viewModel.upcomingOnly = upcomingOnly
setHasOptionsMenu(true)
}
override fun onRecyclerViewCreated(recyclerView: RecyclerView, savedInstanceState: Bundle?) = with(recyclerView) {
layoutManager = LinearLayoutManager(recyclerView.context)
addItemDecoration(DividerItemDecoration(recyclerView.context, DividerItemDecoration.VERTICAL))
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
setAdapter(adapter)
emptyText = getString(R.string.no_bookmark)
isProgressBarVisible = true
viewModel.bookmarks.observe(viewLifecycleOwner) { bookmarks ->
adapter.submitList(bookmarks)
isProgressBarVisible = false
}
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.bookmarks, menu)
filterMenuItem = menu.findItem(R.id.filter)
upcomingOnlyMenuItem = menu.findItem(R.id.upcoming_only)
updateMenuItems()
}
private fun updateMenuItems() {
val upcomingOnly = viewModel.upcomingOnly
filterMenuItem?.setIcon(if (upcomingOnly) R.drawable.ic_filter_list_selected_white_24dp else R.drawable.ic_filter_list_white_24dp)
upcomingOnlyMenuItem?.isChecked = upcomingOnly
}
override fun onDestroyOptionsMenu() {
super.onDestroyOptionsMenu()
filterMenuItem = null
upcomingOnlyMenuItem = null
}
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
R.id.upcoming_only -> {
val upcomingOnly = !viewModel.upcomingOnly
viewModel.upcomingOnly = upcomingOnly
updateMenuItems()
requireActivity().getPreferences(Context.MODE_PRIVATE).edit {
putBoolean(PREF_UPCOMING_ONLY, upcomingOnly)
}
true
}
R.id.export_bookmarks -> {
val exportIntent = BookmarksExportProvider.getIntent(activity)
startActivity(Intent.createChooser(exportIntent, getString(R.string.export_bookmarks)))
true
}
else -> false
}
override fun createNfcAppData(): NdefRecord? {
val context = context ?: return null
val bookmarks = viewModel.bookmarks.value
return if (bookmarks.isNullOrEmpty()) {
null
} else bookmarks.toBookmarksNfcAppData(context)
}
companion object {
private const val PREF_UPCOMING_ONLY = "bookmarks_upcoming_only"
}
}

View file

@ -1,346 +0,0 @@
package be.digitalia.fosdem.fragments;
import android.content.ActivityNotFoundException;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.provider.CalendarContract;
import android.text.Spannable;
import android.text.SpannableString;
import android.text.SpannableStringBuilder;
import android.text.TextPaint;
import android.text.TextUtils;
import android.text.method.LinkMovementMethod;
import android.text.style.ClickableSpan;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import com.google.android.material.snackbar.Snackbar;
import java.text.DateFormat;
import java.util.Date;
import java.util.List;
import androidx.annotation.NonNull;
import androidx.browser.customtabs.CustomTabsIntent;
import androidx.core.app.ShareCompat;
import androidx.core.content.ContextCompat;
import androidx.fragment.app.Fragment;
import androidx.lifecycle.ViewModelProvider;
import be.digitalia.fosdem.R;
import be.digitalia.fosdem.activities.PersonInfoActivity;
import be.digitalia.fosdem.api.FosdemApi;
import be.digitalia.fosdem.model.Building;
import be.digitalia.fosdem.model.Event;
import be.digitalia.fosdem.model.EventDetails;
import be.digitalia.fosdem.model.Link;
import be.digitalia.fosdem.model.Person;
import be.digitalia.fosdem.model.RoomStatus;
import be.digitalia.fosdem.utils.ClickableArrowKeyMovementMethod;
import be.digitalia.fosdem.utils.CustomTabsUtils;
import be.digitalia.fosdem.utils.DateUtils;
import be.digitalia.fosdem.utils.StringUtils;
import be.digitalia.fosdem.viewmodels.EventDetailsViewModel;
public class EventDetailsFragment extends Fragment {
static class ViewHolder {
LayoutInflater inflater;
TextView personsTextView;
TextView roomStatus;
View linksHeader;
ViewGroup linksContainer;
}
private static final String ARG_EVENT = "event";
Event event;
ViewHolder holder;
EventDetailsViewModel viewModel;
public static EventDetailsFragment newInstance(Event event) {
EventDetailsFragment f = new EventDetailsFragment();
Bundle args = new Bundle();
args.putParcelable(ARG_EVENT, event);
f.setArguments(args);
return f;
}
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
event = requireArguments().getParcelable(ARG_EVENT);
viewModel = new ViewModelProvider(this).get(EventDetailsViewModel.class);
viewModel.setEvent(event);
setHasOptionsMenu(true);
}
public Event getEvent() {
return event;
}
@Override
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
View view = inflater.inflate(R.layout.fragment_event_details, container, false);
holder = new ViewHolder();
holder.inflater = inflater;
((TextView) view.findViewById(R.id.title)).setText(event.getTitle());
TextView textView = view.findViewById(R.id.subtitle);
String text = event.getSubTitle();
if (TextUtils.isEmpty(text)) {
textView.setVisibility(View.GONE);
} else {
textView.setText(text);
}
// Set the persons summary text first; replace it with the clickable text when the loader completes
holder.personsTextView = view.findViewById(R.id.persons);
String personsSummary = event.getPersonsSummary();
if (TextUtils.isEmpty(personsSummary)) {
holder.personsTextView.setVisibility(View.GONE);
} else {
holder.personsTextView.setText(personsSummary);
holder.personsTextView.setMovementMethod(LinkMovementMethod.getInstance());
holder.personsTextView.setVisibility(View.VISIBLE);
}
textView = view.findViewById(R.id.time);
Date startTime = event.getStartTime();
Date endTime = event.getEndTime();
DateFormat timeDateFormat = DateUtils.getTimeDateFormat(getActivity());
text = String.format("%1$s, %2$s ― %3$s",
event.getDay().toString(),
(startTime != null) ? timeDateFormat.format(startTime) : "?",
(endTime != null) ? timeDateFormat.format(endTime) : "?");
textView.setText(text);
textView.setContentDescription(getString(R.string.time_content_description, text));
textView = view.findViewById(R.id.room);
final String roomName = event.getRoomName();
Spannable roomText = new SpannableString(String.format("%1$s (Building %2$s)", roomName, Building.fromRoomName(roomName)));
final int roomImageResId = getResources().getIdentifier(StringUtils.roomNameToResourceName(roomName), "drawable", requireActivity().getPackageName());
// If the room image exists, make the room text clickable to display it
if (roomImageResId != 0) {
roomText.setSpan(new ClickableSpan() {
@Override
public void onClick(@NonNull View view) {
RoomImageDialogFragment.newInstance(roomName, roomImageResId).show(getParentFragmentManager());
}
@Override
public void updateDrawState(@NonNull TextPaint ds) {
super.updateDrawState(ds);
ds.setUnderlineText(false);
}
}, 0, roomText.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
textView.setMovementMethod(LinkMovementMethod.getInstance());
}
textView.setText(roomText);
textView.setContentDescription(getString(R.string.room_content_description, roomText));
holder.roomStatus = view.findViewById(R.id.room_status);
textView = view.findViewById(R.id.abstract_text);
text = event.getAbstractText();
if (TextUtils.isEmpty(text)) {
textView.setVisibility(View.GONE);
} else {
textView.setText(StringUtils.parseHtml(text, getResources()));
textView.setMovementMethod(ClickableArrowKeyMovementMethod.getInstance());
}
textView = view.findViewById(R.id.description);
text = event.getDescription();
if (TextUtils.isEmpty(text)) {
textView.setVisibility(View.GONE);
} else {
textView.setText(StringUtils.parseHtml(text, getResources()));
textView.setMovementMethod(ClickableArrowKeyMovementMethod.getInstance());
}
holder.linksHeader = view.findViewById(R.id.links_header);
holder.linksContainer = view.findViewById(R.id.links_container);
return view;
}
@Override
public void onActivityCreated(Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
viewModel.getEventDetails().observe(getViewLifecycleOwner(), eventDetails -> {
if (eventDetails != null) {
setEventDetails(eventDetails);
}
});
// Live room status
FosdemApi.getRoomStatuses(requireContext()).observe(getViewLifecycleOwner(), roomStatuses -> {
RoomStatus roomStatus = roomStatuses.get(event.getRoomName());
if (roomStatus == null) {
holder.roomStatus.setText(null);
} else {
holder.roomStatus.setText(roomStatus.getNameResId());
holder.roomStatus.setTextColor(ContextCompat.getColorStateList(requireContext(), roomStatus.getColorResId()));
}
});
}
@Override
public void onDestroyView() {
super.onDestroyView();
holder = null;
}
@Override
public void onCreateOptionsMenu(@NonNull Menu menu, MenuInflater inflater) {
inflater.inflate(R.menu.event, menu);
menu.findItem(R.id.share).setIntent(getShareChooserIntent());
}
private Intent getShareChooserIntent() {
return ShareCompat.IntentBuilder.from(requireActivity())
.setSubject(String.format("%1$s (FOSDEM)", event.getTitle()))
.setType("text/plain")
.setText(String.format("%1$s %2$s #FOSDEM", event.getTitle(), event.getUrl()))
.setChooserTitle(R.string.share)
.createChooserIntent();
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
switch (item.getItemId()) {
case R.id.add_to_agenda:
addToAgenda();
return true;
}
return false;
}
private void addToAgenda() {
Intent intent = new Intent(Intent.ACTION_EDIT);
intent.setType("vnd.android.cursor.item/event");
intent.putExtra(CalendarContract.Events.TITLE, event.getTitle());
intent.putExtra(CalendarContract.Events.EVENT_LOCATION, "ULB - " + event.getRoomName());
String description = event.getAbstractText();
if (TextUtils.isEmpty(description)) {
description = event.getDescription();
}
description = StringUtils.stripHtml(description);
// Add speaker info if available
EventDetails details = viewModel.getEventDetails().getValue();
final int personsCount = (details == null) ? 0 : details.getPersons().size();
if (personsCount > 0) {
description = String.format("%1$s: %2$s\n\n%3$s", getResources().getQuantityString(R.plurals.speakers, personsCount), event.getPersonsSummary(), description);
}
intent.putExtra(CalendarContract.Events.DESCRIPTION, description);
Date time = event.getStartTime();
if (time != null) {
intent.putExtra(CalendarContract.EXTRA_EVENT_BEGIN_TIME, time.getTime());
}
time = event.getEndTime();
if (time != null) {
intent.putExtra(CalendarContract.EXTRA_EVENT_END_TIME, time.getTime());
}
try {
startActivity(intent);
} catch (ActivityNotFoundException e) {
Snackbar.make(requireView(), R.string.calendar_not_found, Snackbar.LENGTH_LONG).show();
}
}
void setEventDetails(@NonNull EventDetails eventDetails) {
// 1. Persons
final List<Person> persons = eventDetails.getPersons();
if (persons.size() > 0) {
// Build a list of clickable persons
SpannableStringBuilder sb = new SpannableStringBuilder();
int length = 0;
for (Person person : persons) {
if (length != 0) {
sb.append(", ");
}
String name = person.getName();
sb.append(name);
length = sb.length();
sb.setSpan(new PersonClickableSpan(person), length - name.length(), length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
}
holder.personsTextView.setText(sb);
holder.personsTextView.setVisibility(View.VISIBLE);
}
// 2. Links
final List<Link> links = eventDetails.getLinks();
holder.linksContainer.removeAllViews();
if (links.size() > 0) {
holder.linksHeader.setVisibility(View.VISIBLE);
holder.linksContainer.setVisibility(View.VISIBLE);
for (Link link : links) {
View view = holder.inflater.inflate(R.layout.item_link, holder.linksContainer, false);
TextView tv = view.findViewById(R.id.description);
tv.setText(link.getDescription());
view.setOnClickListener(new LinkClickListener(event, link));
holder.linksContainer.addView(view);
}
} else {
holder.linksHeader.setVisibility(View.GONE);
holder.linksContainer.setVisibility(View.GONE);
}
}
private static class PersonClickableSpan extends ClickableSpan {
private final Person person;
PersonClickableSpan(Person person) {
this.person = person;
}
@Override
public void onClick(@NonNull View v) {
Context context = v.getContext();
Intent intent = new Intent(context, PersonInfoActivity.class).putExtra(PersonInfoActivity.EXTRA_PERSON, person);
context.startActivity(intent);
}
@Override
public void updateDrawState(@NonNull TextPaint ds) {
super.updateDrawState(ds);
ds.setUnderlineText(false);
}
}
private static class LinkClickListener implements View.OnClickListener {
private final Event event;
private final Link link;
LinkClickListener(@NonNull Event event, @NonNull Link link) {
this.event = event;
this.link = link;
}
@Override
public void onClick(View v) {
String url = link.getUrl();
try {
final Context context = v.getContext();
CustomTabsUtils.configureToolbarColors(new CustomTabsIntent.Builder(), context, event.getTrack().getType().getAppBarColorResId())
.setShowTitle(true)
.setStartAnimations(context, R.anim.slide_in_right, R.anim.slide_out_left)
.setExitAnimations(context, R.anim.slide_in_left, R.anim.slide_out_right)
.build()
.launchUrl(context, Uri.parse(url));
} catch (ActivityNotFoundException ignore) {
}
}
}
}

View file

@ -0,0 +1,318 @@
package be.digitalia.fosdem.fragments
import android.annotation.SuppressLint
import android.content.ActivityNotFoundException
import android.content.Intent
import android.os.Bundle
import android.provider.CalendarContract
import android.text.Spannable
import android.text.SpannableString
import android.text.TextPaint
import android.text.method.LinkMovementMethod
import android.text.style.ClickableSpan
import android.view.*
import android.widget.TextView
import androidx.browser.customtabs.CustomTabsIntent
import androidx.core.app.ShareCompat
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import androidx.core.text.buildSpannedString
import androidx.core.text.inSpans
import androidx.core.text.set
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.observe
import be.digitalia.fosdem.R
import be.digitalia.fosdem.activities.PersonInfoActivity
import be.digitalia.fosdem.api.FosdemApi
import be.digitalia.fosdem.model.*
import be.digitalia.fosdem.utils.*
import be.digitalia.fosdem.viewmodels.EventDetailsViewModel
import com.google.android.material.snackbar.Snackbar
class EventDetailsFragment : Fragment() {
private class ViewHolder(view: View) {
val personsTextView: TextView = view.findViewById(R.id.persons)
val roomStatusTextView: TextView = view.findViewById(R.id.room_status)
val linksHeader: View = view.findViewById(R.id.links_header)
val linksContainer: ViewGroup = view.findViewById(R.id.links_container)
}
private val viewModel: EventDetailsViewModel by viewModels()
private var holder: ViewHolder? = null
val event by lazy<Event>(LazyThreadSafetyMode.NONE) {
requireArguments().getParcelable(ARG_EVENT)!!
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setHasOptionsMenu(true)
}
@SuppressLint("SetTextI18n")
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val view = inflater.inflate(R.layout.fragment_event_details, container, false)
holder = ViewHolder(view).apply {
view.findViewById<TextView>(R.id.title).text = event.title
view.findViewById<TextView>(R.id.subtitle).apply {
val subTitle = event.subTitle
if (subTitle.isNullOrEmpty()) {
isVisible = false
} else {
text = subTitle
}
}
personsTextView.apply {
// Set the persons summary text first;
// replace it with the clickable text when the event details loading completes
val personsSummary = event.personsSummary
if (personsSummary.isNullOrEmpty()) {
isVisible = false
} else {
text = personsSummary
movementMethod = LinkMovementMethod.getInstance()
isVisible = true
}
}
view.findViewById<TextView>(R.id.time).apply {
val timeDateFormat = DateUtils.getTimeDateFormat(requireContext())
val startTime = event.startTime?.let { timeDateFormat.format(it) } ?: "?"
val endTime = event.endTime?.let { timeDateFormat.format(it) } ?: "?"
text = "${event.day}, $startTime$endTime"
contentDescription = getString(R.string.time_content_description, text)
}
view.findViewById<TextView>(R.id.room).apply {
val roomName = event.roomName
if (roomName.isNullOrEmpty()) {
isVisible = false
} else {
val building = Building.fromRoomName(roomName)
val roomText: Spannable = SpannableString("$roomName (Building $building)")
val roomImageResId = resources.getIdentifier(roomNameToResourceName(roomName), "drawable", requireActivity().packageName)
// If the room image exists, make the room text clickable to display it
if (roomImageResId != 0) {
roomText[0, roomText.length] = object : ClickableSpan() {
override fun onClick(view: View) {
RoomImageDialogFragment.newInstance(roomName, roomImageResId).show(parentFragmentManager)
}
override fun updateDrawState(ds: TextPaint) {
super.updateDrawState(ds)
ds.isUnderlineText = false
}
}
movementMethod = LinkMovementMethod.getInstance()
}
text = roomText
contentDescription = getString(R.string.room_content_description, roomText)
}
}
view.findViewById<TextView>(R.id.abstract_text).apply {
val abstractText = event.abstractText
if (abstractText.isNullOrEmpty()) {
isVisible = false
} else {
text = abstractText.parseHtml(resources)
movementMethod = ClickableArrowKeyMovementMethod
}
}
view.findViewById<TextView>(R.id.description).apply {
val descriptionText = event.description
if (descriptionText.isNullOrEmpty()) {
isVisible = false
} else {
text = descriptionText.parseHtml(resources)
movementMethod = ClickableArrowKeyMovementMethod
}
}
}
return view
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
with(viewModel) {
setEvent(event)
eventDetails.observe(viewLifecycleOwner) { eventDetails ->
showEventDetails(eventDetails)
}
}
// Live room status
val roomName = event.roomName
if (!roomName.isNullOrEmpty()) {
holder?.roomStatusTextView?.run {
FosdemApi.getRoomStatuses(requireContext()).observe(viewLifecycleOwner) { roomStatuses ->
val roomStatus = roomStatuses[roomName]
if (roomStatus == null) {
text = null
} else {
setText(roomStatus.nameResId)
setTextColor(ContextCompat.getColorStateList(requireContext(), roomStatus.colorResId))
}
}
}
}
}
override fun onDestroyView() {
super.onDestroyView()
holder = null
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.event, menu)
menu.findItem(R.id.share)?.intent = createShareChooserIntent()
}
private fun createShareChooserIntent(): Intent {
val title = event.title ?: ""
val url = event.url ?: ""
return ShareCompat.IntentBuilder.from(requireActivity())
.setSubject("$title ($CONFERENCE_NAME)")
.setType("text/plain")
.setText("$title $url $CONFERENCE_HASHTAG")
.setChooserTitle(R.string.share)
.createChooserIntent()
}
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
R.id.add_to_agenda -> {
addToAgenda()
true
}
else -> false
}
private fun addToAgenda() {
val intent = Intent(Intent.ACTION_EDIT).apply {
type = "vnd.android.cursor.item/event"
event.title?.let { putExtra(CalendarContract.Events.TITLE, it) }
val roomName = event.roomName
val location = if (roomName.isNullOrEmpty()) VENUE_NAME else "$VENUE_NAME - $roomName"
putExtra(CalendarContract.Events.EVENT_LOCATION, location)
var description = event.abstractText
if (description.isNullOrEmpty()) {
description = event.description ?: ""
}
description = description.stripHtml()
// Add speaker info if available
val personsCount = viewModel.eventDetails.value?.persons?.size ?: 0
if (personsCount > 0) {
val personsSummary = event.personsSummary ?: "?"
val speakersLabel = resources.getQuantityString(R.plurals.speakers, personsCount)
description = "$speakersLabel: $personsSummary\n\n$description"
}
putExtra(CalendarContract.Events.DESCRIPTION, description)
event.startTime?.let { putExtra(CalendarContract.EXTRA_EVENT_BEGIN_TIME, it.time) }
event.endTime?.let { putExtra(CalendarContract.EXTRA_EVENT_END_TIME, it.time) }
}
try {
startActivity(intent)
} catch (e: ActivityNotFoundException) {
Snackbar.make(requireView(), R.string.calendar_not_found, Snackbar.LENGTH_LONG).show()
}
}
private fun showEventDetails(eventDetails: EventDetails) {
holder?.run {
val (persons, links) = eventDetails
// 1. Persons
if (persons.isNotEmpty()) {
// Build a list of clickable persons
val clickablePersonsSummary = buildSpannedString {
for (person in persons) {
val name = person.name
if (name.isNullOrEmpty()) {
continue
}
if (length != 0) {
append(", ")
}
inSpans(PersonClickableSpan(person)) {
append(name)
}
}
}
personsTextView.text = clickablePersonsSummary
personsTextView.isVisible = true
}
// 2. Links
linksContainer.removeAllViews()
if (links.isNotEmpty()) {
linksHeader.isVisible = true
linksContainer.isVisible = true
val inflater = layoutInflater
for (link in links) {
val view = inflater.inflate(R.layout.item_link, linksContainer, false)
view.findViewById<TextView>(R.id.description).apply {
text = link.description
}
view.setOnClickListener(LinkClickListener(event, link))
linksContainer.addView(view)
}
} else {
linksHeader.isVisible = false
linksContainer.isVisible = false
}
}
}
private class PersonClickableSpan(private val person: Person) : ClickableSpan() {
override fun onClick(v: View) {
val context = v.context
val intent = Intent(context, PersonInfoActivity::class.java)
.putExtra(PersonInfoActivity.EXTRA_PERSON, person)
context.startActivity(intent)
}
override fun updateDrawState(ds: TextPaint) {
super.updateDrawState(ds)
ds.isUnderlineText = false
}
}
private class LinkClickListener(private val event: Event, private val link: Link) : View.OnClickListener {
override fun onClick(v: View) {
try {
val context = v.context
CustomTabsIntent.Builder()
.configureToolbarColors(context, event.track.type.appBarColorResId)
.setShowTitle(true)
.setStartAnimations(context, R.anim.slide_in_right, R.anim.slide_out_left)
.setExitAnimations(context, R.anim.slide_in_left, R.anim.slide_out_right)
.build()
.launchUrl(context, link.url.toUri())
} catch (ignore: ActivityNotFoundException) {
}
}
}
companion object {
private const val ARG_EVENT = "event"
private const val CONFERENCE_NAME = "FOSDEM"
private const val CONFERENCE_HASHTAG = "#FOSDEM"
private const val VENUE_NAME = "ULB"
fun newInstance(event: Event) = EventDetailsFragment().apply {
arguments = Bundle(1).apply {
putParcelable(ARG_EVENT, event)
}
}
}
}

View file

@ -1,62 +0,0 @@
package be.digitalia.fosdem.fragments;
import android.os.Bundle;
import androidx.annotation.NonNull;
import androidx.lifecycle.Observer;
import androidx.lifecycle.ViewModelProvider;
import androidx.paging.PagedList;
import androidx.recyclerview.widget.DividerItemDecoration;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import be.digitalia.fosdem.R;
import be.digitalia.fosdem.adapters.EventsAdapter;
import be.digitalia.fosdem.model.StatusEvent;
import be.digitalia.fosdem.viewmodels.ExternalBookmarksViewModel;
public class ExternalBookmarksListFragment extends RecyclerViewFragment implements Observer<PagedList<StatusEvent>> {
private static final String ARG_BOOKMARK_IDS = "bookmark_ids";
private EventsAdapter adapter;
public static ExternalBookmarksListFragment newInstance(@NonNull long[] bookmarkIds) {
ExternalBookmarksListFragment f = new ExternalBookmarksListFragment();
Bundle args = new Bundle();
args.putLongArray(ARG_BOOKMARK_IDS, bookmarkIds);
f.setArguments(args);
return f;
}
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
adapter = new EventsAdapter(getContext(), this);
}
@Override
protected void onRecyclerViewCreated(RecyclerView recyclerView, Bundle savedInstanceState) {
recyclerView.setLayoutManager(new LinearLayoutManager(recyclerView.getContext()));
recyclerView.addItemDecoration(new DividerItemDecoration(recyclerView.getContext(), DividerItemDecoration.VERTICAL));
}
@Override
public void onActivityCreated(Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
setAdapter(adapter);
setEmptyText(getString(R.string.no_bookmark));
setProgressBarVisible(true);
long[] bookmarkIds = requireArguments().getLongArray(ARG_BOOKMARK_IDS);
final ExternalBookmarksViewModel viewModel = new ViewModelProvider(this).get(ExternalBookmarksViewModel.class);
viewModel.setBookmarkIds(bookmarkIds);
viewModel.getBookmarks().observe(getViewLifecycleOwner(), this);
}
@Override
public void onChanged(PagedList<StatusEvent> bookmarks) {
adapter.submitList(bookmarks);
setProgressBarVisible(false);
}
}

View file

@ -0,0 +1,52 @@
package be.digitalia.fosdem.fragments
import android.os.Bundle
import androidx.fragment.app.viewModels
import androidx.lifecycle.observe
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import be.digitalia.fosdem.R
import be.digitalia.fosdem.adapters.EventsAdapter
import be.digitalia.fosdem.viewmodels.ExternalBookmarksViewModel
class ExternalBookmarksListFragment : RecyclerViewFragment() {
private val viewModel: ExternalBookmarksViewModel by viewModels()
private val adapter by lazy(LazyThreadSafetyMode.NONE) {
EventsAdapter(requireContext(), this)
}
override fun onRecyclerViewCreated(recyclerView: RecyclerView, savedInstanceState: Bundle?) = with(recyclerView) {
layoutManager = LinearLayoutManager(context)
addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL))
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
setAdapter(adapter)
emptyText = getString(R.string.no_bookmark)
isProgressBarVisible = true
val bookmarkIds = requireArguments().getLongArray(ARG_BOOKMARK_IDS)!!
with(viewModel) {
setBookmarkIds(bookmarkIds)
bookmarks.observe(viewLifecycleOwner) { bookmarks ->
adapter.submitList(bookmarks)
isProgressBarVisible = false
}
}
}
companion object {
private const val ARG_BOOKMARK_IDS = "bookmark_ids"
fun newInstance(bookmarkIds: LongArray) = ExternalBookmarksListFragment().apply {
arguments = Bundle(1).apply {
putLongArray(ARG_BOOKMARK_IDS, bookmarkIds)
}
}
}
}

View file

@ -1,96 +0,0 @@
package be.digitalia.fosdem.fragments;
import android.content.res.Resources;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.fragment.app.Fragment;
import androidx.recyclerview.widget.RecyclerView;
import androidx.viewpager2.adapter.FragmentStateAdapter;
import androidx.viewpager2.widget.ViewPager2;
import com.google.android.material.tabs.TabLayout;
import com.google.android.material.tabs.TabLayoutMediator;
import be.digitalia.fosdem.R;
import be.digitalia.fosdem.utils.RecyclerViewUtils;
public class LiveFragment extends Fragment implements RecycledViewPoolProvider {
static class ViewHolder {
ViewPager2 pager;
TabLayout tabs;
RecyclerView.RecycledViewPool recycledViewPool;
}
private ViewHolder holder;
@Override
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
View view = inflater.inflate(R.layout.fragment_live, container, false);
holder = new ViewHolder();
holder.pager = view.findViewById(R.id.pager);
final LivePagerAdapter adapter = new LivePagerAdapter(this);
holder.pager.setAdapter(adapter);
holder.pager.setOffscreenPageLimit(1);
RecyclerViewUtils.enforceSingleScrollDirection(RecyclerViewUtils.getRecyclerView(holder.pager));
holder.tabs = view.findViewById(R.id.tabs);
new TabLayoutMediator(holder.tabs, holder.pager, false,
(tab, position) -> tab.setText(adapter.getPageTitle(position))).attach();
holder.recycledViewPool = new RecyclerView.RecycledViewPool();
return view;
}
@Override
public void onDestroyView() {
super.onDestroyView();
holder = null;
}
@Override
public RecyclerView.RecycledViewPool getRecycledViewPool() {
return (holder == null) ? null : holder.recycledViewPool;
}
private static class LivePagerAdapter extends FragmentStateAdapter {
private final Resources resources;
LivePagerAdapter(Fragment fragment) {
super(fragment.getChildFragmentManager(), fragment.getViewLifecycleOwner().getLifecycle());
this.resources = fragment.getResources();
}
@Override
public int getItemCount() {
return 2;
}
@NonNull
@Override
public Fragment createFragment(int position) {
switch (position) {
case 0:
return new NextLiveListFragment();
case 1:
return new NowLiveListFragment();
}
throw new IllegalStateException();
}
CharSequence getPageTitle(int position) {
switch (position) {
case 0:
return resources.getString(R.string.next);
case 1:
return resources.getString(R.string.now);
}
return null;
}
}
}

View file

@ -0,0 +1,75 @@
package be.digitalia.fosdem.fragments
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.RecyclerView.RecycledViewPool
import androidx.viewpager2.adapter.FragmentStateAdapter
import androidx.viewpager2.widget.ViewPager2
import be.digitalia.fosdem.R
import be.digitalia.fosdem.utils.enforceSingleScrollDirection
import be.digitalia.fosdem.utils.recyclerView
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator
import com.google.android.material.tabs.TabLayoutMediator.TabConfigurationStrategy
class LiveFragment : Fragment(), RecycledViewPoolProvider {
private class ViewHolder(view: View) {
val pager: ViewPager2 = view.findViewById(R.id.pager)
val tabs: TabLayout = view.findViewById(R.id.tabs)
val recycledViewPool = RecycledViewPool()
}
private var holder: ViewHolder? = null
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val view = inflater.inflate(R.layout.fragment_live, container, false)
val pagerAdapter = LivePagerAdapter(this)
holder = ViewHolder(view).apply {
pager.apply {
adapter = pagerAdapter
offscreenPageLimit = 1
recyclerView.enforceSingleScrollDirection()
}
TabLayoutMediator(tabs, pager, false,
TabConfigurationStrategy { tab, position -> tab.text = pagerAdapter.getPageTitle(position) }
).attach()
}
return view
}
override fun onDestroyView() {
super.onDestroyView()
holder = null
}
override val recycledViewPool: RecycledViewPool?
get() = holder?.recycledViewPool
private class LivePagerAdapter(fragment: Fragment)
: FragmentStateAdapter(fragment.childFragmentManager, fragment.viewLifecycleOwner.lifecycle) {
private val resources = fragment.resources
override fun getItemCount() = 2
override fun createFragment(position: Int): Fragment = when (position) {
0 -> NextLiveListFragment()
1 -> NowLiveListFragment()
else -> throw IllegalStateException()
}
fun getPageTitle(position: Int): CharSequence? = when (position) {
0 -> resources.getString(R.string.next)
1 -> resources.getString(R.string.now)
else -> null
}
}
}

View file

@ -0,0 +1,60 @@
package be.digitalia.fosdem.fragments
import android.os.Bundle
import androidx.annotation.StringRes
import androidx.fragment.app.viewModels
import androidx.lifecycle.LiveData
import androidx.lifecycle.observe
import androidx.paging.PagedList
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import be.digitalia.fosdem.R
import be.digitalia.fosdem.adapters.EventsAdapter
import be.digitalia.fosdem.model.StatusEvent
import be.digitalia.fosdem.viewmodels.LiveViewModel
sealed class LiveListFragment(@StringRes private val emptyTextResId: Int,
private val dataSourceProvider: (LiveViewModel) -> LiveData<PagedList<StatusEvent>>)
: RecyclerViewFragment() {
private val viewModel: LiveViewModel by viewModels({ requireParentFragment() })
private val adapter by lazy(LazyThreadSafetyMode.NONE) {
EventsAdapter(requireContext(), this, false)
}
private val preserveScrollPositionRunnable = Runnable {
// Ensure we stay at scroll position 0 so we can see the insertion animation
recyclerView?.run {
if (scrollY == 0) {
scrollToPosition(0)
}
}
}
override fun onRecyclerViewCreated(recyclerView: RecyclerView, savedInstanceState: Bundle?) = with(recyclerView) {
val parent = parentFragment
if (parent is RecycledViewPoolProvider) {
setRecycledViewPool(parent.recycledViewPool)
}
layoutManager = LinearLayoutManager(context)
addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL))
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
setAdapter(adapter)
emptyText = getString(emptyTextResId)
isProgressBarVisible = true
dataSourceProvider(viewModel).observe(viewLifecycleOwner) { events ->
adapter.submitList(events, preserveScrollPositionRunnable)
isProgressBarVisible = false
}
}
}
class NextLiveListFragment : LiveListFragment(R.string.next_empty, LiveViewModel::nextEvents)
class NowLiveListFragment : LiveListFragment(R.string.now_empty, LiveViewModel::eventsInProgress)

View file

@ -1,90 +0,0 @@
package be.digitalia.fosdem.fragments;
import android.content.ActivityNotFoundException;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import androidx.annotation.NonNull;
import androidx.browser.customtabs.CustomTabsIntent;
import androidx.fragment.app.Fragment;
import java.util.Locale;
import be.digitalia.fosdem.R;
import be.digitalia.fosdem.api.FosdemUrls;
import be.digitalia.fosdem.utils.CustomTabsUtils;
import be.digitalia.fosdem.utils.ThemeUtils;
public class MapFragment extends Fragment {
private static final double DESTINATION_LATITUDE = 50.812375;
private static final double DESTINATION_LONGITUDE = 4.380734;
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setHasOptionsMenu(true);
}
@Override
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
final View view = inflater.inflate(R.layout.fragment_map, container, false);
final ImageView imageView = view.findViewById(R.id.map);
if (!ThemeUtils.isLightTheme(imageView.getContext())) {
ThemeUtils.invertImageColors(imageView);
}
return view;
}
@Override
public void onCreateOptionsMenu(@NonNull Menu menu, MenuInflater inflater) {
inflater.inflate(R.menu.map, menu);
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
switch (item.getItemId()) {
case R.id.directions:
launchDirections();
return true;
case R.id.navigation:
launchLocalNavigation();
return true;
}
return false;
}
private void launchDirections() {
// Build intent to start Google Maps directions
String uri = String.format(Locale.US,
"https://maps.google.com/maps?f=d&daddr=%1$f,%2$f&dirflg=r",
DESTINATION_LATITUDE, DESTINATION_LONGITUDE);
Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(uri));
try {
startActivity(intent);
} catch (ActivityNotFoundException ignore) {
}
}
private void launchLocalNavigation() {
try {
final Context context = requireContext();
CustomTabsUtils.configureToolbarColors(new CustomTabsIntent.Builder(), context, R.color.light_color_primary)
.setShowTitle(true)
.build()
.launchUrl(context, Uri.parse(FosdemUrls.getLocalNavigation()));
} catch (ActivityNotFoundException ignore) {
}
}
}

View file

@ -0,0 +1,76 @@
package be.digitalia.fosdem.fragments
import android.content.ActivityNotFoundException
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.*
import android.widget.ImageView
import androidx.browser.customtabs.CustomTabsIntent
import androidx.core.net.toUri
import androidx.fragment.app.Fragment
import be.digitalia.fosdem.R
import be.digitalia.fosdem.api.FosdemUrls.localNavigation
import be.digitalia.fosdem.utils.configureToolbarColors
import be.digitalia.fosdem.utils.invertImageColors
import be.digitalia.fosdem.utils.isLightTheme
class MapFragment : Fragment() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setHasOptionsMenu(true)
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val view = inflater.inflate(R.layout.fragment_map, container, false)
view.findViewById<ImageView>(R.id.map).apply {
if (!context.isLightTheme) {
invertImageColors()
}
}
return view
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) = inflater.inflate(R.menu.map, menu)
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
R.id.directions -> {
launchDirections()
true
}
R.id.navigation -> {
launchLocalNavigation()
true
}
else -> false
}
private fun launchDirections() {
// Build intent to start Google Maps directions
val uri = "https://maps.google.com/maps?f=d&daddr=${DESTINATION_LATITUDE},${DESTINATION_LONGITUDE}&dirflg=r".toUri()
val intent = Intent(Intent.ACTION_VIEW, uri)
try {
startActivity(intent)
} catch (ignore: ActivityNotFoundException) {
}
}
private fun launchLocalNavigation() {
try {
val context = requireContext()
CustomTabsIntent.Builder()
.configureToolbarColors(context, R.color.light_color_primary)
.setShowTitle(true)
.build()
.launchUrl(context, Uri.parse(localNavigation))
} catch (ignore: ActivityNotFoundException) {
}
}
companion object {
private const val DESTINATION_LATITUDE = 50.812375
private const val DESTINATION_LONGITUDE = 4.380734
}
}

View file

@ -1,22 +0,0 @@
package be.digitalia.fosdem.fragments;
import androidx.annotation.NonNull;
import androidx.lifecycle.LiveData;
import androidx.paging.PagedList;
import be.digitalia.fosdem.R;
import be.digitalia.fosdem.model.StatusEvent;
import be.digitalia.fosdem.viewmodels.LiveViewModel;
public class NextLiveListFragment extends BaseLiveListFragment {
@Override
protected String getEmptyText() {
return getString(R.string.next_empty);
}
@NonNull
@Override
protected LiveData<PagedList<StatusEvent>> getDataSource(@NonNull LiveViewModel viewModel) {
return viewModel.getNextEvents();
}
}

View file

@ -1,22 +0,0 @@
package be.digitalia.fosdem.fragments;
import androidx.annotation.NonNull;
import androidx.lifecycle.LiveData;
import androidx.paging.PagedList;
import be.digitalia.fosdem.R;
import be.digitalia.fosdem.model.StatusEvent;
import be.digitalia.fosdem.viewmodels.LiveViewModel;
public class NowLiveListFragment extends BaseLiveListFragment {
@Override
protected String getEmptyText() {
return getString(R.string.now_empty);
}
@NonNull
@Override
protected LiveData<PagedList<StatusEvent>> getDataSource(@NonNull LiveViewModel viewModel) {
return viewModel.getEventsInProgress();
}
}

View file

@ -1,153 +0,0 @@
package be.digitalia.fosdem.fragments;
import android.content.ActivityNotFoundException;
import android.content.Context;
import android.net.Uri;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.browser.customtabs.CustomTabsIntent;
import androidx.lifecycle.Observer;
import androidx.lifecycle.ViewModelProvider;
import androidx.paging.PagedList;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import be.digitalia.fosdem.R;
import be.digitalia.fosdem.adapters.ConcatAdapter;
import be.digitalia.fosdem.adapters.EventsAdapter;
import be.digitalia.fosdem.model.Person;
import be.digitalia.fosdem.model.StatusEvent;
import be.digitalia.fosdem.utils.CustomTabsUtils;
import be.digitalia.fosdem.utils.DateUtils;
import be.digitalia.fosdem.viewmodels.PersonInfoViewModel;
public class PersonInfoListFragment extends RecyclerViewFragment implements Observer<PagedList<StatusEvent>> {
private static final String ARG_PERSON = "person";
private Person person;
private EventsAdapter adapter;
public static PersonInfoListFragment newInstance(Person person) {
PersonInfoListFragment f = new PersonInfoListFragment();
Bundle args = new Bundle();
args.putParcelable(ARG_PERSON, person);
f.setArguments(args);
return f;
}
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
adapter = new EventsAdapter(getContext(), this);
person = requireArguments().getParcelable(ARG_PERSON);
setHasOptionsMenu(true);
}
@Override
public void onCreateOptionsMenu(@NonNull Menu menu, MenuInflater inflater) {
inflater.inflate(R.menu.person, menu);
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
switch (item.getItemId()) {
case R.id.more_info:
// Look for the first non-placeholder event in the paged list
final PagedList<StatusEvent> list = adapter.getCurrentList();
final int size = (list == null) ? 0 : list.size();
StatusEvent statusEvent = null;
for (int i = 0; i < size; ++i) {
statusEvent = list.get(i);
if (statusEvent != null) {
break;
}
}
if (statusEvent != null) {
final int year = DateUtils.getYear(statusEvent.getEvent().getDay().getDate().getTime());
String url = person.getUrl(year);
if (url != null) {
try {
final Context context = requireContext();
CustomTabsUtils.configureToolbarColors(new CustomTabsIntent.Builder(), context, R.color.light_color_primary)
.setStartAnimations(context, R.anim.slide_in_right, R.anim.slide_out_left)
.setExitAnimations(context, R.anim.slide_in_left, R.anim.slide_out_right)
.build()
.launchUrl(context, Uri.parse(url));
} catch (ActivityNotFoundException ignore) {
}
}
}
return true;
}
return false;
}
@Override
protected void onRecyclerViewCreated(RecyclerView recyclerView, Bundle savedInstanceState) {
final int contentMargin = getResources().getDimensionPixelSize(R.dimen.content_margin);
recyclerView.setPadding(contentMargin, contentMargin, contentMargin, contentMargin);
recyclerView.setClipToPadding(false);
recyclerView.setScrollBarStyle(View.SCROLLBARS_OUTSIDE_OVERLAY);
recyclerView.setLayoutManager(new LinearLayoutManager(recyclerView.getContext()));
}
@Override
public void onActivityCreated(Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
setAdapter(new ConcatAdapter(new HeaderAdapter(), adapter));
setEmptyText(getString(R.string.no_data));
setProgressBarVisible(true);
final PersonInfoViewModel viewModel = new ViewModelProvider(this).get(PersonInfoViewModel.class);
viewModel.setPerson(person);
viewModel.getEvents().observe(getViewLifecycleOwner(), this);
}
@Override
public void onChanged(PagedList<StatusEvent> events) {
adapter.submitList(events);
setProgressBarVisible(false);
}
static class HeaderAdapter extends RecyclerView.Adapter<HeaderAdapter.ViewHolder> {
@Override
public int getItemCount() {
return 1;
}
@Override
public int getItemViewType(int position) {
return R.layout.header_person_info;
}
@NonNull
@Override
public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.header_person_info, parent, false);
return new ViewHolder(view);
}
@Override
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
// Nothing to bind
}
static class ViewHolder extends RecyclerView.ViewHolder {
ViewHolder(View itemView) {
super(itemView);
}
}
}
}

View file

@ -0,0 +1,115 @@
package be.digitalia.fosdem.fragments
import android.content.ActivityNotFoundException
import android.net.Uri
import android.os.Bundle
import android.view.*
import androidx.browser.customtabs.CustomTabsIntent
import androidx.fragment.app.viewModels
import androidx.lifecycle.observe
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import be.digitalia.fosdem.R
import be.digitalia.fosdem.adapters.ConcatAdapter
import be.digitalia.fosdem.adapters.EventsAdapter
import be.digitalia.fosdem.model.Person
import be.digitalia.fosdem.utils.DateUtils
import be.digitalia.fosdem.utils.configureToolbarColors
import be.digitalia.fosdem.viewmodels.PersonInfoViewModel
class PersonInfoListFragment : RecyclerViewFragment() {
private val viewModel: PersonInfoViewModel by viewModels()
private val person by lazy<Person>(LazyThreadSafetyMode.NONE) {
requireArguments().getParcelable(ARG_PERSON)!!
}
private val adapter by lazy(LazyThreadSafetyMode.NONE) {
EventsAdapter(requireContext(), this)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setHasOptionsMenu(true)
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.person, menu)
}
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
R.id.more_info -> {
// Look for the first non-placeholder event in the paged list
val statusEvent = adapter.currentList?.firstOrNull { it != null }
if (statusEvent != null) {
val year = DateUtils.getYear(statusEvent.event.day.date.time)
val url = person.getUrl(year)
if (url != null) {
try {
val context = requireContext()
CustomTabsIntent.Builder()
.configureToolbarColors(context, R.color.light_color_primary)
.setStartAnimations(context, R.anim.slide_in_right, R.anim.slide_out_left)
.setExitAnimations(context, R.anim.slide_in_left, R.anim.slide_out_right)
.build()
.launchUrl(context, Uri.parse(url))
} catch (ignore: ActivityNotFoundException) {
}
}
}
true
}
else -> false
}
override fun onRecyclerViewCreated(recyclerView: RecyclerView, savedInstanceState: Bundle?) = with(recyclerView) {
val contentMargin = resources.getDimensionPixelSize(R.dimen.content_margin)
setPadding(contentMargin, contentMargin, contentMargin, contentMargin)
clipToPadding = false
scrollBarStyle = View.SCROLLBARS_OUTSIDE_OVERLAY
layoutManager = LinearLayoutManager(context)
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
setAdapter(ConcatAdapter(HeaderAdapter(), adapter))
emptyText = getString(R.string.no_data)
isProgressBarVisible = true
with(viewModel) {
setPerson(person)
events.observe(viewLifecycleOwner) { events ->
adapter.submitList(events)
isProgressBarVisible = false
}
}
}
private class HeaderAdapter : RecyclerView.Adapter<HeaderAdapter.ViewHolder>() {
override fun getItemCount() = 1
override fun getItemViewType(position: Int) = R.layout.header_person_info
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(parent.context).inflate(R.layout.header_person_info, parent, false)
return ViewHolder(view)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
// Nothing to bind
}
private class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView)
}
companion object {
private const val ARG_PERSON = "person"
fun newInstance(person: Person) = PersonInfoListFragment().apply {
arguments = Bundle(1).apply {
putParcelable(ARG_PERSON, person)
}
}
}
}

View file

@ -1,130 +0,0 @@
package be.digitalia.fosdem.fragments;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.util.ObjectsCompat;
import androidx.lifecycle.Observer;
import androidx.lifecycle.ViewModelProvider;
import androidx.paging.PagedList;
import androidx.paging.PagedListAdapter;
import androidx.recyclerview.widget.DiffUtil;
import androidx.recyclerview.widget.DividerItemDecoration;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import be.digitalia.fosdem.R;
import be.digitalia.fosdem.activities.PersonInfoActivity;
import be.digitalia.fosdem.adapters.SimpleItemCallback;
import be.digitalia.fosdem.model.Person;
import be.digitalia.fosdem.viewmodels.PersonsViewModel;
public class PersonsListFragment extends RecyclerViewFragment implements Observer<PagedList<Person>> {
private PersonsAdapter adapter;
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
adapter = new PersonsAdapter();
}
@NonNull
@Override
protected RecyclerView onCreateRecyclerView(LayoutInflater inflater, ViewGroup container, @Nullable Bundle savedInstanceState) {
return (RecyclerView) inflater.inflate(R.layout.recyclerview_fastscroll, container, false);
}
@Override
protected void onRecyclerViewCreated(RecyclerView recyclerView, Bundle savedInstanceState) {
recyclerView.setLayoutManager(new LinearLayoutManager(recyclerView.getContext()));
recyclerView.addItemDecoration(new DividerItemDecoration(recyclerView.getContext(), DividerItemDecoration.VERTICAL));
}
@Override
public void onActivityCreated(Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
setAdapter(adapter);
setEmptyText(getString(R.string.no_data));
setProgressBarVisible(true);
final PersonsViewModel viewModel = new ViewModelProvider(this).get(PersonsViewModel.class);
viewModel.getPersons().observe(getViewLifecycleOwner(), this);
}
@Override
public void onChanged(PagedList<Person> persons) {
adapter.submitList(persons);
setProgressBarVisible(false);
}
private static class PersonsAdapter extends PagedListAdapter<Person, PersonViewHolder> {
private static final DiffUtil.ItemCallback<Person> DIFF_CALLBACK = new SimpleItemCallback<Person>() {
@Override
public boolean areContentsTheSame(@NonNull Person oldItem, @NonNull Person newItem) {
return ObjectsCompat.equals(oldItem.getName(), newItem.getName());
}
};
PersonsAdapter() {
super(DIFF_CALLBACK);
}
@NonNull
@Override
public PersonViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.simple_list_item_1_material, parent, false);
return new PersonViewHolder(view);
}
@Override
public void onBindViewHolder(@NonNull PersonViewHolder holder, int position) {
final Person person = getItem(position);
if (person == null) {
holder.clear();
} else {
holder.bind(person);
}
}
}
static class PersonViewHolder extends RecyclerView.ViewHolder implements View.OnClickListener {
final TextView textView;
Person person;
PersonViewHolder(@NonNull View itemView) {
super(itemView);
textView = itemView.findViewById(android.R.id.text1);
itemView.setOnClickListener(this);
}
void clear() {
this.person = null;
textView.setText(null);
}
void bind(@NonNull Person person) {
this.person = person;
textView.setText(person.getName());
}
@Override
public void onClick(View view) {
if (person != null) {
final Context context = view.getContext();
Intent intent = new Intent(context, PersonInfoActivity.class)
.putExtra(PersonInfoActivity.EXTRA_PERSON, person);
context.startActivity(intent);
}
}
}
}

View file

@ -0,0 +1,99 @@
package be.digitalia.fosdem.fragments
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.fragment.app.viewModels
import androidx.lifecycle.observe
import androidx.paging.PagedListAdapter
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import be.digitalia.fosdem.R
import be.digitalia.fosdem.activities.PersonInfoActivity
import be.digitalia.fosdem.adapters.createSimpleItemCallback
import be.digitalia.fosdem.model.Person
import be.digitalia.fosdem.viewmodels.PersonsViewModel
class PersonsListFragment : RecyclerViewFragment() {
private val adapter = PersonsAdapter()
private val viewModel: PersonsViewModel by viewModels()
override fun onCreateRecyclerView(inflater: LayoutInflater, container: ViewGroup, savedInstanceState: Bundle?): RecyclerView {
return inflater.inflate(R.layout.recyclerview_fastscroll, container, false) as RecyclerView
}
override fun onRecyclerViewCreated(recyclerView: RecyclerView, savedInstanceState: Bundle?) = with(recyclerView) {
layoutManager = LinearLayoutManager(context)
addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL))
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
setAdapter(adapter)
emptyText = getString(R.string.no_data)
isProgressBarVisible = true
viewModel.persons.observe(viewLifecycleOwner) { persons ->
adapter.submitList(persons)
isProgressBarVisible = false
}
}
private class PersonsAdapter : PagedListAdapter<Person, PersonViewHolder>(DIFF_CALLBACK) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PersonViewHolder {
val view = LayoutInflater.from(parent.context).inflate(R.layout.simple_list_item_1_material, parent, false)
return PersonViewHolder(view)
}
override fun onBindViewHolder(holder: PersonViewHolder, position: Int) {
val person = getItem(position)
if (person == null) {
holder.clear()
} else {
holder.bind(person)
}
}
companion object {
private val DIFF_CALLBACK = createSimpleItemCallback<Person> { oldItem, newItem ->
oldItem.id == newItem.id
}
}
}
private class PersonViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView), View.OnClickListener {
val textView: TextView = itemView.findViewById(android.R.id.text1)
var person: Person? = null
init {
itemView.setOnClickListener(this)
}
fun clear() {
person = null
textView.text = null
}
fun bind(person: Person) {
this.person = person
textView.text = person.name
}
override fun onClick(view: View) {
person?.let {
val context = view.context
val intent = Intent(context, PersonInfoActivity::class.java)
.putExtra(PersonInfoActivity.EXTRA_PERSON, it)
context.startActivity(intent)
}
}
}
}

View file

@ -1,10 +0,0 @@
package be.digitalia.fosdem.fragments;
import androidx.recyclerview.widget.RecyclerView;
/**
* Components implementing this interface allow to share a RecycledViewPool between similar fragments.
*/
public interface RecycledViewPoolProvider {
RecyclerView.RecycledViewPool getRecycledViewPool();
}

View file

@ -0,0 +1,10 @@
package be.digitalia.fosdem.fragments
import androidx.recyclerview.widget.RecyclerView
/**
* Components implementing this interface allow to share a RecycledViewPool between similar fragments.
*/
interface RecycledViewPoolProvider {
val recycledViewPool: RecyclerView.RecycledViewPool?
}

View file

@ -1,189 +0,0 @@
package be.digitalia.fosdem.fragments;
import android.content.Context;
import android.os.Bundle;
import android.view.Gravity;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.FrameLayout;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import androidx.recyclerview.widget.RecyclerView;
import be.digitalia.fosdem.widgets.ContentLoadingProgressBar;
/**
* Fragment providing a RecyclerView, an empty view and a progress bar.
*
* @author Christophe Beyls
*/
public class RecyclerViewFragment extends Fragment {
private static final float DEFAULT_EMPTY_VIEW_PADDING_DIPS = 16f;
static class ViewHolder {
FrameLayout container;
RecyclerView recyclerView;
View emptyView;
ContentLoadingProgressBar progress;
}
private ViewHolder mHolder;
private boolean mIsProgressBarVisible;
private final RecyclerView.AdapterDataObserver mEmptyObserver = new RecyclerView.AdapterDataObserver() {
@Override
public void onChanged() {
updateEmptyViewVisibility();
}
@Override
public void onItemRangeInserted(int positionStart, int itemCount) {
updateEmptyViewVisibility();
}
@Override
public void onItemRangeRemoved(int positionStart, int itemCount) {
updateEmptyViewVisibility();
}
};
/**
* Override this method to provide a custom RecyclerView.
* The default one is using the theme's recyclerViewStyle.
*/
@NonNull
protected RecyclerView onCreateRecyclerView(LayoutInflater inflater, ViewGroup container, @Nullable Bundle savedInstanceState) {
return new RecyclerView(inflater.getContext());
}
/**
* Override this method to provide a custom Empty View.
* The default one is a TextView with some padding.
*/
@NonNull
protected View onCreateEmptyView(LayoutInflater inflater, ViewGroup container, @Nullable Bundle savedInstanceState) {
TextView textView = new TextView(inflater.getContext());
textView.setGravity(Gravity.CENTER);
int textPadding = (int) (getResources().getDisplayMetrics().density * DEFAULT_EMPTY_VIEW_PADDING_DIPS + 0.5f);
textView.setPadding(textPadding, textPadding, textPadding, textPadding);
return textView;
}
/**
* Override this method to setup the RecyclerView (LayoutManager, ItemDecoration, ...)
*/
protected void onRecyclerViewCreated(RecyclerView recyclerView, @Nullable Bundle savedInstanceState) {
}
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
final Context context = inflater.getContext();
mHolder = new ViewHolder();
mHolder.container = new FrameLayout(context);
mHolder.recyclerView = onCreateRecyclerView(inflater, mHolder.container, savedInstanceState);
mHolder.recyclerView.setId(android.R.id.list);
mHolder.recyclerView.setDescendantFocusability(ViewGroup.FOCUS_AFTER_DESCENDANTS);
mHolder.recyclerView.setHasFixedSize(true);
mHolder.container.addView(mHolder.recyclerView,
new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
mHolder.emptyView = onCreateEmptyView(inflater, mHolder.container, savedInstanceState);
mHolder.emptyView.setId(android.R.id.empty);
mHolder.emptyView.setVisibility(View.GONE);
mHolder.container.addView(mHolder.emptyView,
new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
mHolder.progress = new ContentLoadingProgressBar(context, null, android.R.attr.progressBarStyleLarge);
mHolder.progress.setId(android.R.id.progress);
mHolder.progress.hide();
mHolder.container.addView(mHolder.progress,
new FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.CENTER));
mHolder.container.setLayoutParams(new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
onRecyclerViewCreated(mHolder.recyclerView, savedInstanceState);
return mHolder.container;
}
@Override
public void onDestroyView() {
// Ensure the RecyclerView and emptyObserver are properly unregistered from the adapter
setAdapter(null);
mHolder = null;
mIsProgressBarVisible = false;
super.onDestroyView();
}
/**
* Get the fragments's RecyclerView widget.
*/
public RecyclerView getRecyclerView() {
final ViewHolder holder = mHolder;
return (holder == null) ? null : holder.recyclerView;
}
/**
* Call this method to set the RecyclerView's adapter while ensuring the empty view
* will show or hide automatically according to the adapter's empty state.
*/
public void setAdapter(@Nullable RecyclerView.Adapter adapter) {
final RecyclerView.Adapter oldAdapter = mHolder.recyclerView.getAdapter();
if (oldAdapter == adapter) {
return;
}
if (oldAdapter != null) {
oldAdapter.unregisterAdapterDataObserver(mEmptyObserver);
}
mHolder.recyclerView.setAdapter(adapter);
if (adapter != null) {
adapter.registerAdapterDataObserver(mEmptyObserver);
}
updateEmptyViewVisibility();
}
/**
* The default content for a RecyclerViewFragment has a TextView that can be shown when the list is empty.
* Call this method to supply the text it should use.
*/
public void setEmptyText(CharSequence text) {
((TextView) mHolder.emptyView).setText(text);
}
void updateEmptyViewVisibility() {
if (!mIsProgressBarVisible) {
RecyclerView.Adapter adapter = mHolder.recyclerView.getAdapter();
final boolean isEmptyViewVisible = (adapter != null) && (adapter.getItemCount() == 0);
mHolder.emptyView.setVisibility(isEmptyViewVisible ? View.VISIBLE : View.GONE);
}
}
/**
* Call this method to show or hide the indeterminate progress bar.
* When shown, the RecyclerView will be hidden.
*
* @param visible true to show the progress bar, false to hide it. The initial value is false.
*/
public void setProgressBarVisible(boolean visible) {
if (mIsProgressBarVisible != visible) {
mIsProgressBarVisible = visible;
if (visible) {
mHolder.recyclerView.setVisibility(View.GONE);
mHolder.emptyView.setVisibility(View.GONE);
mHolder.progress.show();
} else {
mHolder.recyclerView.setVisibility(View.VISIBLE);
updateEmptyViewVisibility();
mHolder.progress.hide();
}
}
}
}

View file

@ -0,0 +1,175 @@
package be.digitalia.fosdem.fragments
import android.os.Bundle
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.TextView
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.AdapterDataObserver
import be.digitalia.fosdem.widgets.ContentLoadingProgressBar
/**
* Fragment providing a RecyclerView, an empty view and a progress bar.
*
* @author Christophe Beyls
*/
open class RecyclerViewFragment : Fragment() {
private class ViewHolder(val recyclerView: RecyclerView,
val emptyView: View,
val progress: ContentLoadingProgressBar)
private var holder: ViewHolder? = null
private val emptyObserver: AdapterDataObserver = object : AdapterDataObserver() {
override fun onChanged() {
updateEmptyViewVisibility()
}
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
updateEmptyViewVisibility()
}
override fun onItemRangeRemoved(positionStart: Int, itemCount: Int) {
updateEmptyViewVisibility()
}
}
/**
* Override this method to provide a custom RecyclerView.
* The default one is using the theme's recyclerViewStyle.
*/
protected open fun onCreateRecyclerView(inflater: LayoutInflater, container: ViewGroup, savedInstanceState: Bundle?): RecyclerView {
return RecyclerView(inflater.context)
}
/**
* Override this method to provide a custom Empty View.
* The default one is a TextView with some padding.
*/
protected open fun onCreateEmptyView(inflater: LayoutInflater, container: ViewGroup, savedInstanceState: Bundle?): View {
return TextView(inflater.context).apply {
gravity = Gravity.CENTER
val textPadding = (resources.displayMetrics.density * DEFAULT_EMPTY_VIEW_PADDING_DIPS + 0.5f).toInt()
setPadding(textPadding, textPadding, textPadding, textPadding)
}
}
/**
* Override this method to setup the RecyclerView (LayoutManager, ItemDecoration, ...)
*/
protected open fun onRecyclerViewCreated(recyclerView: RecyclerView, savedInstanceState: Bundle?) {}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val context = inflater.context
val subContainer = FrameLayout(context).apply {
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
}
val recyclerView = onCreateRecyclerView(inflater, subContainer, savedInstanceState).apply {
id = android.R.id.list
descendantFocusability = ViewGroup.FOCUS_AFTER_DESCENDANTS
setHasFixedSize(true)
subContainer.addView(this, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
}
val emptyView = onCreateEmptyView(inflater, subContainer, savedInstanceState).apply {
id = android.R.id.empty
isVisible = false
subContainer.addView(this, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
}
val progress = ContentLoadingProgressBar(context, null, android.R.attr.progressBarStyleLarge).apply {
id = android.R.id.progress
hide()
subContainer.addView(this, FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.CENTER))
}
holder = ViewHolder(recyclerView, emptyView, progress)
onRecyclerViewCreated(recyclerView, savedInstanceState)
return subContainer
}
override fun onDestroyView() {
// Ensure the RecyclerView and emptyObserver are properly unregistered from the adapter
setAdapter(null)
holder = null
isProgressBarVisible = false
super.onDestroyView()
}
/**
* Get the fragments's RecyclerView widget.
*/
val recyclerView: RecyclerView?
get() = holder?.recyclerView
/**
* Call this method to set the RecyclerView's adapter while ensuring the empty view
* will show or hide automatically according to the adapter's empty state.
*/
fun setAdapter(adapter: RecyclerView.Adapter<*>?) {
val viewHolder = requireNotNull(holder)
val oldAdapter = viewHolder.recyclerView.adapter
if (oldAdapter === adapter) {
return
}
oldAdapter?.unregisterAdapterDataObserver(emptyObserver)
viewHolder.recyclerView.adapter = adapter
adapter?.registerAdapterDataObserver(emptyObserver)
updateEmptyViewVisibility()
}
/**
* The default content for a RecyclerViewFragment has a TextView that can be shown when the list is empty.
* Call this method to supply the text it should use.
*/
var emptyText: CharSequence?
get() = (holder?.emptyView as? TextView)?.text
set(value) {
(holder?.emptyView as? TextView)?.text = value
}
private fun updateEmptyViewVisibility() {
if (!isProgressBarVisible) {
holder?.run {
emptyView.isVisible = recyclerView.adapter?.itemCount == 0
}
}
}
/**
* Set this field to show or hide the indeterminate progress bar.
* When shown, the RecyclerView will be hidden.The initial value is false.
*/
var isProgressBarVisible: Boolean = false
set(visible) {
if (field != visible) {
field = visible
holder?.run {
if (visible) {
recyclerView.isVisible = false
emptyView.isVisible = false
progress.show()
} else {
recyclerView.isVisible = true
updateEmptyViewVisibility()
progress.hide()
}
}
}
}
companion object {
private const val DEFAULT_EMPTY_VIEW_PADDING_DIPS = 16f
}
}

View file

@ -1,68 +0,0 @@
package be.digitalia.fosdem.fragments;
import android.annotation.SuppressLint;
import android.app.Dialog;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.Window;
import android.widget.ImageView;
import androidx.annotation.DrawableRes;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AlertDialog;
import androidx.fragment.app.DialogFragment;
import androidx.fragment.app.FragmentManager;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import be.digitalia.fosdem.R;
import be.digitalia.fosdem.activities.RoomImageDialogActivity;
import be.digitalia.fosdem.utils.ThemeUtils;
public class RoomImageDialogFragment extends DialogFragment {
public static final String TAG = "room";
private static final String ARG_ROOM_NAME = "roomName";
private static final String ARG_ROOM_IMAGE_RESOURCE_ID = "imageResId";
public static RoomImageDialogFragment newInstance(String roomName, @DrawableRes int imageResId) {
RoomImageDialogFragment f = new RoomImageDialogFragment();
Bundle args = new Bundle();
args.putString(ARG_ROOM_NAME, roomName);
args.putInt(ARG_ROOM_IMAGE_RESOURCE_ID, imageResId);
f.setArguments(args);
return f;
}
@NonNull
@Override
@SuppressLint("InflateParams")
public Dialog onCreateDialog(Bundle savedInstanceState) {
Bundle args = requireArguments();
AlertDialog.Builder dialogBuilder = new MaterialAlertDialogBuilder(requireContext());
View contentView = LayoutInflater.from(dialogBuilder.getContext()).inflate(R.layout.dialog_room_image, null);
final ImageView imageView = contentView.findViewById(R.id.room_image);
if (!ThemeUtils.isLightTheme(imageView.getContext())) {
ThemeUtils.invertImageColors(imageView);
}
imageView.setImageResource(args.getInt(ARG_ROOM_IMAGE_RESOURCE_ID));
RoomImageDialogActivity.configureToolbar(this, contentView.findViewById(R.id.toolbar), args.getString(ARG_ROOM_NAME));
Dialog dialog = dialogBuilder
.setView(contentView)
.create();
Window window = dialog.getWindow();
if (window != null) {
window.getAttributes().windowAnimations = R.style.RoomImageDialogAnimations;
}
return dialog;
}
public void show(FragmentManager manager) {
show(manager, TAG);
}
}

View file

@ -0,0 +1,59 @@
package be.digitalia.fosdem.fragments
import android.annotation.SuppressLint
import android.app.Dialog
import android.os.Bundle
import android.view.LayoutInflater
import android.widget.ImageView
import androidx.annotation.DrawableRes
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.FragmentManager
import be.digitalia.fosdem.R
import be.digitalia.fosdem.activities.RoomImageDialogActivity
import be.digitalia.fosdem.utils.invertImageColors
import be.digitalia.fosdem.utils.isLightTheme
import com.google.android.material.dialog.MaterialAlertDialogBuilder
class RoomImageDialogFragment : DialogFragment() {
@SuppressLint("InflateParams")
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val args = requireArguments()
val dialogBuilder: AlertDialog.Builder = MaterialAlertDialogBuilder(requireContext())
val contentView = LayoutInflater.from(dialogBuilder.context).inflate(R.layout.dialog_room_image, null)
contentView.findViewById<ImageView>(R.id.room_image).apply {
if (!context.isLightTheme) {
invertImageColors()
}
setImageResource(args.getInt(ARG_ROOM_IMAGE_RESOURCE_ID))
}
RoomImageDialogActivity.configureToolbar(this, contentView.findViewById(R.id.toolbar), args.getString(ARG_ROOM_NAME)!!)
return dialogBuilder
.setView(contentView)
.create()
.apply {
window?.attributes?.windowAnimations = R.style.RoomImageDialogAnimations
}
}
fun show(manager: FragmentManager) {
show(manager, TAG)
}
companion object {
const val TAG = "room"
private const val ARG_ROOM_NAME = "roomName"
private const val ARG_ROOM_IMAGE_RESOURCE_ID = "imageResId"
fun newInstance(roomName: String, @DrawableRes imageResId: Int) = RoomImageDialogFragment().apply {
arguments = Bundle(2).apply {
putString(ARG_ROOM_NAME, roomName)
putInt(ARG_ROOM_IMAGE_RESOURCE_ID, imageResId)
}
}
}
}

View file

@ -1,53 +0,0 @@
package be.digitalia.fosdem.fragments;
import android.os.Bundle;
import androidx.lifecycle.Observer;
import androidx.lifecycle.ViewModelProvider;
import androidx.paging.PagedList;
import androidx.recyclerview.widget.DividerItemDecoration;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import be.digitalia.fosdem.R;
import be.digitalia.fosdem.adapters.EventsAdapter;
import be.digitalia.fosdem.model.StatusEvent;
import be.digitalia.fosdem.viewmodels.SearchViewModel;
public class SearchResultListFragment extends RecyclerViewFragment implements Observer<PagedList<StatusEvent>> {
private EventsAdapter adapter;
public static SearchResultListFragment newInstance() {
return new SearchResultListFragment();
}
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
adapter = new EventsAdapter(getContext(), this);
}
@Override
protected void onRecyclerViewCreated(RecyclerView recyclerView, Bundle savedInstanceState) {
recyclerView.setLayoutManager(new LinearLayoutManager(recyclerView.getContext()));
recyclerView.addItemDecoration(new DividerItemDecoration(recyclerView.getContext(), DividerItemDecoration.VERTICAL));
}
@Override
public void onActivityCreated(Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
setAdapter(adapter);
setEmptyText(getString(R.string.no_search_result));
setProgressBarVisible(true);
final SearchViewModel viewModel = new ViewModelProvider(requireActivity()).get(SearchViewModel.class);
viewModel.getResults().observe(getViewLifecycleOwner(), this);
}
@Override
public void onChanged(PagedList<StatusEvent> results) {
adapter.submitList(results);
setProgressBarVisible(false);
}
}

View file

@ -0,0 +1,39 @@
package be.digitalia.fosdem.fragments
import android.os.Bundle
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.observe
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import be.digitalia.fosdem.R
import be.digitalia.fosdem.adapters.EventsAdapter
import be.digitalia.fosdem.viewmodels.SearchViewModel
class SearchResultListFragment : RecyclerViewFragment() {
private val viewModel: SearchViewModel by activityViewModels()
private val adapter by lazy(LazyThreadSafetyMode.NONE) {
EventsAdapter(requireContext(), this)
}
override fun onRecyclerViewCreated(recyclerView: RecyclerView, savedInstanceState: Bundle?) = with(recyclerView) {
layoutManager = LinearLayoutManager(context)
addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL))
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
setAdapter(adapter)
emptyText = getString(R.string.no_search_result)
isProgressBarVisible = true
viewModel.results.observe(viewLifecycleOwner) { result ->
adapter.submitList((result as? SearchViewModel.Result.Success)?.list)
isProgressBarVisible = false
}
}
companion object {
fun newInstance() = SearchResultListFragment()
}
}

View file

@ -1,83 +0,0 @@
package be.digitalia.fosdem.fragments;
import android.app.Dialog;
import android.os.Build;
import android.os.Bundle;
import android.text.method.LinkMovementMethod;
import android.widget.TextView;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import androidx.annotation.NonNull;
import androidx.annotation.RequiresApi;
import androidx.fragment.app.DialogFragment;
import androidx.preference.PreferenceFragmentCompat;
import be.digitalia.fosdem.BuildConfig;
import be.digitalia.fosdem.R;
import be.digitalia.fosdem.services.AlarmIntentService;
public class SettingsFragment extends PreferenceFragmentCompat {
public static final String KEY_PREF_THEME = "theme";
public static final String KEY_PREF_NOTIFICATIONS_ENABLED = "notifications_enabled";
// Android >= O only
public static final String KEY_PREF_NOTIFICATIONS_CHANNEL = "notifications_channel";
// Android < O only
public static final String KEY_PREF_NOTIFICATIONS_VIBRATE = "notifications_vibrate";
// Android < O only
public static final String KEY_PREF_NOTIFICATIONS_LED = "notifications_led";
public static final String KEY_PREF_NOTIFICATIONS_DELAY = "notifications_delay";
private static final String KEY_PREF_ABOUT = "about";
private static final String KEY_PREF_VERSION = "version";
@Override
public void onCreatePreferences(Bundle savedInstanceState, String rootKey) {
setPreferencesFromResource(R.xml.settings, rootKey);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
setupNotificationsChannel();
}
setupAboutDialog();
populateVersion();
}
@RequiresApi(api = Build.VERSION_CODES.O)
private void setupNotificationsChannel() {
findPreference(KEY_PREF_NOTIFICATIONS_CHANNEL).setOnPreferenceClickListener(
preference -> {
AlarmIntentService.startChannelNotificationSettingsActivity(getContext());
return true;
});
}
private void setupAboutDialog() {
findPreference(KEY_PREF_ABOUT).setOnPreferenceClickListener(preference -> {
new AboutDialogFragment().show(getParentFragmentManager(), "about");
return true;
});
}
public static class AboutDialogFragment extends DialogFragment {
@NonNull
@Override
public Dialog onCreateDialog(Bundle savedInstanceState) {
return new MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.app_name)
.setIcon(R.mipmap.ic_launcher)
.setMessage(getResources().getText(R.string.about_text))
.setPositiveButton(android.R.string.ok, null)
.create();
}
@Override
public void onStart() {
super.onStart();
// Make links clickable; must be called after the dialog is shown
((TextView) requireDialog().findViewById(android.R.id.message)).setMovementMethod(LinkMovementMethod.getInstance());
}
}
private void populateVersion() {
findPreference(KEY_PREF_VERSION).setSummary(BuildConfig.VERSION_NAME);
}
}

View file

@ -0,0 +1,64 @@
package be.digitalia.fosdem.fragments
import android.app.Dialog
import android.os.Build
import android.os.Bundle
import android.text.method.LinkMovementMethod
import android.widget.TextView
import androidx.annotation.RequiresApi
import androidx.fragment.app.DialogFragment
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import be.digitalia.fosdem.BuildConfig
import be.digitalia.fosdem.R
import be.digitalia.fosdem.services.AlarmIntentService
import be.digitalia.fosdem.utils.PreferenceKeys
import com.google.android.material.dialog.MaterialAlertDialogBuilder
class SettingsFragment : PreferenceFragmentCompat() {
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.settings, rootKey)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
setupNotificationsChannel()
}
setupAboutDialog()
populateVersion()
}
@RequiresApi(api = Build.VERSION_CODES.O)
private fun setupNotificationsChannel() {
findPreference<Preference>(PreferenceKeys.NOTIFICATIONS_CHANNEL)?.onPreferenceClickListener = Preference.OnPreferenceClickListener {
AlarmIntentService.startChannelNotificationSettingsActivity(requireContext())
true
}
}
private fun setupAboutDialog() {
findPreference<Preference>(PreferenceKeys.ABOUT)?.onPreferenceClickListener = Preference.OnPreferenceClickListener {
AboutDialogFragment().show(parentFragmentManager, "about")
true
}
}
class AboutDialogFragment : DialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
return MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.app_name)
.setIcon(R.mipmap.ic_launcher)
.setMessage(resources.getText(R.string.about_text))
.setPositiveButton(android.R.string.ok, null)
.create()
}
override fun onStart() {
super.onStart()
// Make links clickable; must be called after the dialog is shown
requireDialog().findViewById<TextView>(android.R.id.message).movementMethod = LinkMovementMethod.getInstance()
}
}
private fun populateVersion() {
findPreference<Preference>(PreferenceKeys.VERSION)?.summary = BuildConfig.VERSION_NAME
}
}

View file

@ -1,176 +0,0 @@
package be.digitalia.fosdem.fragments;
import android.content.Context;
import android.os.Bundle;
import java.util.List;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.lifecycle.Observer;
import androidx.lifecycle.ViewModelProvider;
import androidx.recyclerview.widget.DividerItemDecoration;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import be.digitalia.fosdem.R;
import be.digitalia.fosdem.adapters.TrackScheduleAdapter;
import be.digitalia.fosdem.model.Day;
import be.digitalia.fosdem.model.Event;
import be.digitalia.fosdem.model.StatusEvent;
import be.digitalia.fosdem.model.Track;
import be.digitalia.fosdem.viewmodels.TrackScheduleViewModel;
public class TrackScheduleListFragment extends RecyclerViewFragment
implements TrackScheduleAdapter.EventClickListener, Observer<List<StatusEvent>> {
/**
* Interface implemented by container activities
*/
public interface Callbacks {
void onEventSelected(int position, Event event);
}
private static final String ARG_DAY = "day";
private static final String ARG_TRACK = "track";
private static final String ARG_FROM_EVENT_ID = "from_event_id";
private static final String STATE_IS_LIST_ALREADY_SHOWN = "isListAlreadyShown";
private static final String STATE_SELECTED_ID = "selectedId";
private TrackScheduleAdapter adapter;
private TrackScheduleViewModel viewModel;
private Callbacks listener;
private boolean selectionEnabled = false;
private long selectedId = -1L;
private boolean isListAlreadyShown = false;
public static TrackScheduleListFragment newInstance(Day day, Track track) {
TrackScheduleListFragment f = new TrackScheduleListFragment();
Bundle args = new Bundle();
args.putParcelable(ARG_DAY, day);
args.putParcelable(ARG_TRACK, track);
f.setArguments(args);
return f;
}
public static TrackScheduleListFragment newInstance(Day day, Track track, long fromEventId) {
TrackScheduleListFragment f = new TrackScheduleListFragment();
Bundle args = new Bundle();
args.putParcelable(ARG_DAY, day);
args.putParcelable(ARG_TRACK, track);
args.putLong(ARG_FROM_EVENT_ID, fromEventId);
f.setArguments(args);
return f;
}
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
selectionEnabled = getResources().getBoolean(R.bool.tablet_landscape);
adapter = new TrackScheduleAdapter(getActivity(), this);
final Bundle args = requireArguments();
final Day day = args.getParcelable(ARG_DAY);
final Track track = args.getParcelable(ARG_TRACK);
viewModel = new ViewModelProvider(this).get(TrackScheduleViewModel.class);
viewModel.setTrack(day, track);
viewModel.getCurrentTime().observe(this, now -> adapter.setCurrentTime(now));
if (savedInstanceState != null) {
isListAlreadyShown = savedInstanceState.getBoolean(STATE_IS_LIST_ALREADY_SHOWN);
}
if (savedInstanceState == null) {
setSelectedId(args.getLong(ARG_FROM_EVENT_ID, -1L));
} else {
setSelectedId(savedInstanceState.getLong(STATE_SELECTED_ID));
}
}
private void setSelectedId(long id) {
selectedId = id;
if (selectionEnabled) {
adapter.setSelectedId(id);
}
}
@Override
public void onSaveInstanceState(@NonNull Bundle outState) {
super.onSaveInstanceState(outState);
outState.putBoolean(STATE_IS_LIST_ALREADY_SHOWN, isListAlreadyShown);
outState.putLong(STATE_SELECTED_ID, selectedId);
}
@Override
public void onAttach(@NonNull Context context) {
super.onAttach(context);
if (context instanceof Callbacks) {
listener = (Callbacks) context;
}
}
@Override
public void onDetach() {
super.onDetach();
listener = null;
}
private void notifyEventSelected(int position, Event event) {
if (listener != null) {
listener.onEventSelected(position, event);
}
}
@Override
protected void onRecyclerViewCreated(RecyclerView recyclerView, @Nullable Bundle savedInstanceState) {
recyclerView.setLayoutManager(new LinearLayoutManager(recyclerView.getContext()));
recyclerView.addItemDecoration(new DividerItemDecoration(recyclerView.getContext(), DividerItemDecoration.VERTICAL));
}
@Override
public void onActivityCreated(Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
setAdapter(adapter);
setEmptyText(getString(R.string.no_data));
setProgressBarVisible(true);
viewModel.getSchedule().observe(getViewLifecycleOwner(), this);
}
@Override
public void onEventClick(int position, Event event) {
setSelectedId(event.getId());
notifyEventSelected(position, event);
}
@Override
public void onChanged(List<StatusEvent> schedule) {
adapter.submitList(schedule);
if (selectionEnabled) {
int selectedPosition = adapter.getPositionForId(selectedId);
if (selectedPosition == RecyclerView.NO_POSITION && adapter.getItemCount() > 0) {
// There is no current valid selection, reset to use the first item
setSelectedId(adapter.getItemId(0));
selectedPosition = 0;
}
// Ensure the current selection is visible
if (selectedPosition != RecyclerView.NO_POSITION) {
getRecyclerView().scrollToPosition(selectedPosition);
}
// Notify the parent of the current selection to synchronize its state
notifyEventSelected(selectedPosition, (selectedPosition == RecyclerView.NO_POSITION) ? null : schedule.get(selectedPosition).getEvent());
} else if (!isListAlreadyShown) {
final int position = adapter.getPositionForId(selectedId);
if (position != RecyclerView.NO_POSITION) {
getRecyclerView().scrollToPosition(position);
}
}
isListAlreadyShown = true;
setProgressBarVisible(false);
}
}

View file

@ -0,0 +1,150 @@
package be.digitalia.fosdem.fragments
import android.content.Context
import android.os.Bundle
import androidx.fragment.app.viewModels
import androidx.lifecycle.observe
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import be.digitalia.fosdem.R
import be.digitalia.fosdem.adapters.TrackScheduleAdapter
import be.digitalia.fosdem.model.Day
import be.digitalia.fosdem.model.Event
import be.digitalia.fosdem.model.Track
import be.digitalia.fosdem.viewmodels.TrackScheduleViewModel
class TrackScheduleListFragment : RecyclerViewFragment(), TrackScheduleAdapter.EventClickListener {
/**
* Interface implemented by container activities
*/
interface Callbacks {
fun onEventSelected(position: Int, event: Event?)
}
private val viewModel: TrackScheduleViewModel by viewModels()
private val adapter by lazy(LazyThreadSafetyMode.NONE) {
TrackScheduleAdapter(requireActivity(), this)
}
private var listener: Callbacks? = null
private var selectionEnabled = false
private var isListAlreadyShown = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
selectionEnabled = resources.getBoolean(R.bool.tablet_landscape)
val args = requireArguments()
val day: Day = args.getParcelable(ARG_DAY)!!
val track: Track = args.getParcelable(ARG_TRACK)!!
with(viewModel) {
setDayAndTrack(day, track)
currentTime.observe(this@TrackScheduleListFragment) { now ->
adapter.currentTime = now
}
}
if (savedInstanceState != null) {
isListAlreadyShown = savedInstanceState.getBoolean(STATE_IS_LIST_ALREADY_SHOWN)
}
selectedId = savedInstanceState?.getLong(STATE_SELECTED_ID)
?: args.getLong(ARG_FROM_EVENT_ID, RecyclerView.NO_ID)
}
private var selectedId: Long = RecyclerView.NO_ID
set(value) {
field = value
if (selectionEnabled) {
adapter.selectedId = value
}
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putBoolean(STATE_IS_LIST_ALREADY_SHOWN, isListAlreadyShown)
outState.putLong(STATE_SELECTED_ID, selectedId)
}
override fun onAttach(context: Context) {
super.onAttach(context)
if (context is Callbacks) {
listener = context
}
}
override fun onDetach() {
super.onDetach()
listener = null
}
private fun notifyEventSelected(position: Int, event: Event?) {
listener?.onEventSelected(position, event)
}
override fun onRecyclerViewCreated(recyclerView: RecyclerView, savedInstanceState: Bundle?) = with(recyclerView) {
layoutManager = LinearLayoutManager(context)
addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL))
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
setAdapter(adapter)
emptyText = getString(R.string.no_data)
isProgressBarVisible = true
viewModel.schedule.observe(viewLifecycleOwner) { schedule ->
adapter.submitList(schedule)
if (selectionEnabled) {
var selectedPosition = adapter.getPositionForId(selectedId)
if (selectedPosition == RecyclerView.NO_POSITION && adapter.itemCount > 0) {
// There is no current valid selection, reset to use the first item
selectedId = adapter.getItemId(0)
selectedPosition = 0
}
// Ensure the current selection is visible
if (selectedPosition != RecyclerView.NO_POSITION) {
recyclerView?.scrollToPosition(selectedPosition)
}
// Notify the parent of the current selection to synchronize its state
notifyEventSelected(selectedPosition,
if (selectedPosition == RecyclerView.NO_POSITION) null else schedule[selectedPosition].event)
} else if (!isListAlreadyShown) {
val position = adapter.getPositionForId(selectedId)
if (position != RecyclerView.NO_POSITION) {
recyclerView?.scrollToPosition(position)
}
}
isListAlreadyShown = true
isProgressBarVisible = false
}
}
override fun onEventClick(position: Int, event: Event) {
selectedId = event.id
notifyEventSelected(position, event)
}
companion object {
private const val ARG_DAY = "day"
private const val ARG_TRACK = "track"
private const val ARG_FROM_EVENT_ID = "from_event_id"
private const val STATE_IS_LIST_ALREADY_SHOWN = "isListAlreadyShown"
private const val STATE_SELECTED_ID = "selectedId"
fun newInstance(day: Day, track: Track, fromEventId: Long = RecyclerView.NO_ID) = TrackScheduleListFragment().apply {
arguments = Bundle(3).apply {
putParcelable(ARG_DAY, day)
putParcelable(ARG_TRACK, track)
putLong(ARG_FROM_EVENT_ID, fromEventId)
}
}
}
}

View file

@ -1,172 +0,0 @@
package be.digitalia.fosdem.fragments;
import android.content.Context;
import android.content.SharedPreferences;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import androidx.lifecycle.Observer;
import androidx.recyclerview.widget.RecyclerView;
import androidx.viewpager2.adapter.FragmentStateAdapter;
import androidx.viewpager2.widget.ViewPager2;
import com.google.android.material.tabs.TabLayout;
import com.google.android.material.tabs.TabLayoutMediator;
import java.util.List;
import be.digitalia.fosdem.R;
import be.digitalia.fosdem.db.AppDatabase;
import be.digitalia.fosdem.model.Day;
import be.digitalia.fosdem.utils.RecyclerViewUtils;
public class TracksFragment extends Fragment implements RecycledViewPoolProvider, Observer<List<Day>> {
static class ViewHolder {
View contentView;
View emptyView;
ViewPager2 pager;
TabLayout tabs;
DaysAdapter daysAdapter;
RecyclerView.RecycledViewPool recycledViewPool;
}
private static final String PREF_CURRENT_PAGE = "tracks_current_page";
private ViewHolder holder;
private int savedCurrentPage = -1;
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
if (savedInstanceState == null) {
// Restore the current page from preferences
savedCurrentPage = requireActivity().getPreferences(Context.MODE_PRIVATE).getInt(PREF_CURRENT_PAGE, -1);
}
}
@Override
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
View view = inflater.inflate(R.layout.fragment_tracks, container, false);
holder = new ViewHolder();
holder.contentView = view.findViewById(R.id.content);
holder.emptyView = view.findViewById(android.R.id.empty);
holder.pager = view.findViewById(R.id.pager);
holder.pager.setOffscreenPageLimit(1);
RecyclerViewUtils.enforceSingleScrollDirection(RecyclerViewUtils.getRecyclerView(holder.pager));
holder.tabs = view.findViewById(R.id.tabs);
holder.daysAdapter = new DaysAdapter(this);
holder.recycledViewPool = new RecyclerView.RecycledViewPool();
return view;
}
@Override
public void onDestroyView() {
super.onDestroyView();
holder = null;
}
@Override
public void onActivityCreated(Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
AppDatabase.getInstance(requireContext()).getScheduleDao().getDays()
.observe(getViewLifecycleOwner(), this);
}
@Override
public void onStop() {
super.onStop();
// Save the current page to preferences if it has changed
final int page = holder.pager.getCurrentItem();
SharedPreferences prefs = requireActivity().getPreferences(Context.MODE_PRIVATE);
if (prefs.getInt(PREF_CURRENT_PAGE, -1) != page) {
prefs.edit()
.putInt(PREF_CURRENT_PAGE, page)
.apply();
}
}
@Override
public RecyclerView.RecycledViewPool getRecycledViewPool() {
return (holder == null) ? null : holder.recycledViewPool;
}
@Override
public void onChanged(@Nullable List<Day> days) {
holder.daysAdapter.setDays(days);
final int totalPages = holder.daysAdapter.getItemCount();
if (totalPages == 0) {
holder.contentView.setVisibility(View.GONE);
holder.emptyView.setVisibility(View.VISIBLE);
} else {
holder.contentView.setVisibility(View.VISIBLE);
holder.emptyView.setVisibility(View.GONE);
if (holder.pager.getAdapter() == null) {
holder.pager.setAdapter(holder.daysAdapter);
new TabLayoutMediator(holder.tabs, holder.pager,
(tab, position) -> tab.setText(holder.daysAdapter.getPageTitle(position))).attach();
}
if (savedCurrentPage != -1) {
holder.pager.setCurrentItem(Math.min(savedCurrentPage, totalPages - 1), false);
savedCurrentPage = -1;
}
}
}
private static class DaysAdapter extends FragmentStateAdapter {
private List<Day> days;
DaysAdapter(Fragment fragment) {
super(fragment.getChildFragmentManager(), fragment.getViewLifecycleOwner().getLifecycle());
}
void setDays(List<Day> days) {
if (this.days != days) {
this.days = days;
notifyDataSetChanged();
}
}
@Override
public int getItemCount() {
return (days == null) ? 0 : days.size();
}
@Override
public long getItemId(int position) {
return days.get(position).getIndex();
}
@Override
public boolean containsItem(long itemId) {
final int count = getItemCount();
for (int i = 0; i < count; ++i) {
if (days.get(i).getIndex() == itemId) {
return true;
}
}
return false;
}
@NonNull
@Override
public Fragment createFragment(int position) {
return TracksListFragment.newInstance(days.get(position));
}
CharSequence getPageTitle(int position) {
return days.get(position).toString();
}
}
}

View file

@ -0,0 +1,137 @@
package be.digitalia.fosdem.fragments
import android.content.Context
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.content.edit
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.lifecycle.observe
import androidx.recyclerview.widget.RecyclerView.RecycledViewPool
import androidx.viewpager2.adapter.FragmentStateAdapter
import androidx.viewpager2.widget.ViewPager2
import be.digitalia.fosdem.R
import be.digitalia.fosdem.db.AppDatabase
import be.digitalia.fosdem.model.Day
import be.digitalia.fosdem.utils.enforceSingleScrollDirection
import be.digitalia.fosdem.utils.recyclerView
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator
import com.google.android.material.tabs.TabLayoutMediator.TabConfigurationStrategy
class TracksFragment : Fragment(), RecycledViewPoolProvider {
private class ViewHolder(view: View, fragment: Fragment) {
val contentView: View = view.findViewById(R.id.content)
val emptyView: View = view.findViewById(android.R.id.empty)
val pager: ViewPager2 = view.findViewById(R.id.pager)
val tabs: TabLayout = view.findViewById(R.id.tabs)
val daysAdapter = DaysAdapter(fragment)
val recycledViewPool = RecycledViewPool()
}
private var holder: ViewHolder? = null
private var savedCurrentPage = -1
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (savedInstanceState == null) {
// Restore the current page from preferences
savedCurrentPage = requireActivity().getPreferences(Context.MODE_PRIVATE).getInt(PREF_CURRENT_PAGE, -1)
}
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val view = inflater.inflate(R.layout.fragment_tracks, container, false)
holder = ViewHolder(view, this).apply {
pager.apply {
offscreenPageLimit = 1
recyclerView.enforceSingleScrollDirection()
}
}
return view
}
override fun onDestroyView() {
super.onDestroyView()
holder = null
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
AppDatabase.getInstance(requireContext()).scheduleDao.days.observe(viewLifecycleOwner) { days ->
holder?.run {
daysAdapter.days = days
val totalPages = daysAdapter.itemCount
if (totalPages == 0) {
contentView.isVisible = false
emptyView.isVisible = true
} else {
contentView.isVisible = true
emptyView.isVisible = false
if (pager.adapter == null) {
pager.adapter = daysAdapter
TabLayoutMediator(tabs, pager,
TabConfigurationStrategy { tab, position -> tab.text = daysAdapter.getPageTitle(position) }
).attach()
}
if (savedCurrentPage != -1) {
pager.setCurrentItem(savedCurrentPage.coerceAtMost(totalPages - 1), false)
savedCurrentPage = -1
}
}
}
}
}
override fun onStop() {
super.onStop()
// Save the current page to preferences if it has changed
val page = holder?.pager?.currentItem ?: -1
val prefs = requireActivity().getPreferences(Context.MODE_PRIVATE)
if (prefs.getInt(PREF_CURRENT_PAGE, -1) != page) {
prefs.edit {
putInt(PREF_CURRENT_PAGE, page)
}
}
}
override val recycledViewPool: RecycledViewPool?
get() = holder?.recycledViewPool
private class DaysAdapter(fragment: Fragment)
: FragmentStateAdapter(fragment.childFragmentManager, fragment.viewLifecycleOwner.lifecycle) {
var days: List<Day>? = null
set(value) {
if (field != value) {
field = value
notifyDataSetChanged()
}
}
override fun getItemCount() = days?.size ?: 0
override fun getItemId(position: Int) = days!![position].index.toLong()
override fun containsItem(itemId: Long): Boolean {
return days?.any { it.index.toLong() == itemId } ?: false
}
override fun createFragment(position: Int) = TracksListFragment.newInstance(days!![position])
fun getPageTitle(position: Int) = days!![position].toString()
}
companion object {
private const val PREF_CURRENT_PAGE = "tracks_current_page"
}
}

View file

@ -1,143 +0,0 @@
package be.digitalia.fosdem.fragments;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import java.util.List;
import androidx.annotation.NonNull;
import androidx.core.content.ContextCompat;
import androidx.fragment.app.Fragment;
import androidx.lifecycle.Observer;
import androidx.lifecycle.ViewModelProvider;
import androidx.recyclerview.widget.DiffUtil;
import androidx.recyclerview.widget.DividerItemDecoration;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.ListAdapter;
import androidx.recyclerview.widget.RecyclerView;
import be.digitalia.fosdem.R;
import be.digitalia.fosdem.activities.TrackScheduleActivity;
import be.digitalia.fosdem.adapters.SimpleItemCallback;
import be.digitalia.fosdem.model.Day;
import be.digitalia.fosdem.model.Track;
import be.digitalia.fosdem.viewmodels.TracksViewModel;
public class TracksListFragment extends RecyclerViewFragment implements Observer<List<Track>> {
private static final String ARG_DAY = "day";
private Day day;
private TracksAdapter adapter;
public static TracksListFragment newInstance(Day day) {
TracksListFragment f = new TracksListFragment();
Bundle args = new Bundle();
args.putParcelable(ARG_DAY, day);
f.setArguments(args);
return f;
}
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
day = requireArguments().getParcelable(ARG_DAY);
adapter = new TracksAdapter(day);
}
@Override
protected void onRecyclerViewCreated(RecyclerView recyclerView, Bundle savedInstanceState) {
Fragment parentFragment = getParentFragment();
if (parentFragment instanceof RecycledViewPoolProvider) {
recyclerView.setRecycledViewPool(((RecycledViewPoolProvider) parentFragment).getRecycledViewPool());
}
recyclerView.setLayoutManager(new LinearLayoutManager(recyclerView.getContext()));
recyclerView.addItemDecoration(new DividerItemDecoration(recyclerView.getContext(), DividerItemDecoration.VERTICAL));
}
@Override
public void onActivityCreated(Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
setAdapter(adapter);
setEmptyText(getString(R.string.no_data));
setProgressBarVisible(true);
final TracksViewModel viewModel = new ViewModelProvider(this).get(TracksViewModel.class);
viewModel.setDay(day);
viewModel.getTracks().observe(getViewLifecycleOwner(), this);
}
@Override
public void onChanged(List<Track> tracks) {
adapter.submitList(tracks);
setProgressBarVisible(false);
}
private static class TracksAdapter extends ListAdapter<Track, TrackViewHolder> {
private static final DiffUtil.ItemCallback<Track> DIFF_CALLBACK = new SimpleItemCallback<Track>() {
@Override
public boolean areContentsTheSame(@NonNull Track oldItem, @NonNull Track newItem) {
return oldItem.getName().equals(newItem.getName())
&& oldItem.getType().equals(newItem.getType());
}
};
private final Day day;
TracksAdapter(Day day) {
super(DIFF_CALLBACK);
this.day = day;
}
@NonNull
@Override
public TrackViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.simple_list_item_2_material, parent, false);
return new TrackViewHolder(view);
}
@Override
public void onBindViewHolder(@NonNull TrackViewHolder holder, int position) {
holder.bind(day, getItem(position));
}
}
static class TrackViewHolder extends RecyclerView.ViewHolder implements View.OnClickListener {
final TextView name;
final TextView type;
Day day;
Track track;
TrackViewHolder(View itemView) {
super(itemView);
name = itemView.findViewById(android.R.id.text1);
type = itemView.findViewById(android.R.id.text2);
itemView.setOnClickListener(this);
}
void bind(@NonNull Day day, @NonNull Track track) {
this.day = day;
this.track = track;
name.setText(track.getName());
type.setText(track.getType().getNameResId());
type.setTextColor(ContextCompat.getColorStateList(type.getContext(), track.getType().getTextColorResId()));
}
@Override
public void onClick(View view) {
Context context = view.getContext();
Intent intent = new Intent(context, TrackScheduleActivity.class)
.putExtra(TrackScheduleActivity.EXTRA_DAY, day)
.putExtra(TrackScheduleActivity.EXTRA_TRACK, track);
context.startActivity(intent);
}
}
}

View file

@ -0,0 +1,121 @@
package be.digitalia.fosdem.fragments
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.core.content.ContextCompat
import androidx.fragment.app.viewModels
import androidx.lifecycle.observe
import androidx.recyclerview.widget.*
import be.digitalia.fosdem.R
import be.digitalia.fosdem.activities.TrackScheduleActivity
import be.digitalia.fosdem.model.Day
import be.digitalia.fosdem.model.Track
import be.digitalia.fosdem.viewmodels.TracksViewModel
class TracksListFragment : RecyclerViewFragment() {
private val viewModel: TracksViewModel by viewModels()
private val day by lazy<Day>(LazyThreadSafetyMode.NONE) {
requireArguments().getParcelable(ARG_DAY)!!
}
private val adapter by lazy(LazyThreadSafetyMode.NONE) {
TracksAdapter(day)
}
override fun onRecyclerViewCreated(recyclerView: RecyclerView, savedInstanceState: Bundle?) = with(recyclerView) {
val parent = parentFragment
if (parent is RecycledViewPoolProvider) {
setRecycledViewPool(parent.recycledViewPool)
}
layoutManager = LinearLayoutManager(context)
addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL))
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
setAdapter(adapter)
emptyText = getString(R.string.no_data)
isProgressBarVisible = true
with(viewModel) {
setDay(day)
tracks.observe(viewLifecycleOwner) { tracks ->
adapter.submitList(tracks)
isProgressBarVisible = false
}
}
}
private class TracksAdapter(private val day: Day) : ListAdapter<Track, TrackViewHolder>(DIFF_CALLBACK) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TrackViewHolder {
val view = LayoutInflater.from(parent.context).inflate(R.layout.simple_list_item_2_material, parent, false)
return TrackViewHolder(view)
}
override fun onBindViewHolder(holder: TrackViewHolder, position: Int) {
holder.bind(day, getItem(position))
}
companion object {
private val DIFF_CALLBACK = object : DiffUtil.ItemCallback<Track>() {
override fun areItemsTheSame(oldItem: Track, newItem: Track): Boolean {
return oldItem == newItem
}
override fun areContentsTheSame(oldItem: Track, newItem: Track): Boolean {
// Tracks are identified by name and type only, so contents are automatically the same
return true
}
}
}
}
class TrackViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView), View.OnClickListener {
val name: TextView = itemView.findViewById(android.R.id.text1)
val type: TextView = itemView.findViewById(android.R.id.text2)
var day: Day? = null
var track: Track? = null
init {
itemView.setOnClickListener(this)
}
fun bind(day: Day, track: Track) {
this.day = day
this.track = track
name.text = track.name
type.setText(track.type.nameResId)
type.setTextColor(ContextCompat.getColorStateList(type.context, track.type.textColorResId))
}
override fun onClick(view: View) {
val day = this.day
val track = this.track
if (day != null && track != null) {
val context = view.context
val intent = Intent(context, TrackScheduleActivity::class.java)
.putExtra(TrackScheduleActivity.EXTRA_DAY, day)
.putExtra(TrackScheduleActivity.EXTRA_TRACK, track)
context.startActivity(intent)
}
}
}
companion object {
private const val ARG_DAY = "day"
fun newInstance(day: Day) = TracksListFragment().apply {
arguments = Bundle(1).apply {
putParcelable(ARG_DAY, day)
}
}
}
}

View file

@ -1,116 +0,0 @@
package be.digitalia.fosdem.livedata;
import android.os.Handler;
import android.os.Looper;
import android.os.SystemClock;
import androidx.annotation.NonNull;
import androidx.lifecycle.LiveData;
import java.util.Arrays;
import java.util.concurrent.TimeUnit;
public class LiveDataFactory {
static final Handler handler = new Handler(Looper.getMainLooper());
private LiveDataFactory() {
}
private static class IntervalLiveData extends LiveData<Long> implements Runnable {
private final long periodInMillis;
private long updateTime = 0L;
private long version = 0L;
IntervalLiveData(long periodInMillis) {
this.periodInMillis = periodInMillis;
}
@Override
protected void onActive() {
final long now = SystemClock.elapsedRealtime();
if (now >= updateTime) {
update(now);
} else {
handler.postDelayed(this, updateTime - now);
}
}
@Override
protected void onInactive() {
handler.removeCallbacks(this);
}
private void update(long now) {
setValue(version++);
updateTime = now + periodInMillis;
handler.postDelayed(this, periodInMillis);
}
@Override
public void run() {
update(SystemClock.elapsedRealtime());
}
}
public static LiveData<Long> interval(long period, @NonNull TimeUnit unit) {
return new IntervalLiveData(unit.toMillis(period));
}
private static class SchedulerLiveData extends LiveData<Boolean> implements Runnable {
private final long[] startEndTimestamps;
private int nowPosition = -1;
SchedulerLiveData(long[] startEndTimestamps) {
this.startEndTimestamps = startEndTimestamps;
}
@Override
protected void onActive() {
final long now = System.currentTimeMillis();
updateState(now, Arrays.binarySearch(startEndTimestamps, now));
}
@Override
protected void onInactive() {
handler.removeCallbacks(this);
}
@Override
public void run() {
final int position = nowPosition;
updateState(startEndTimestamps[position], position);
}
private void updateState(long now, int position) {
final int size = startEndTimestamps.length;
if (position >= 0) {
do {
position++;
} while (position < size && startEndTimestamps[position] == now);
} else {
position = ~position;
}
final Boolean isOn = position % 2 != 0;
if (getValue() != isOn) {
setValue(isOn);
}
if (position < size) {
nowPosition = position;
handler.postDelayed(this, startEndTimestamps[position] - now);
}
}
}
/**
* Builds a LiveData whose value is true during scheduled periods.
*
* @param startEndTimestamps a list of timestamps in milliseconds, sorted in chronological order.
* Odd and even values represent beginnings and ends of periods, respectively.
*/
public static LiveData<Boolean> scheduler(long... startEndTimestamps) {
return new SchedulerLiveData(startEndTimestamps);
}
}

View file

@ -0,0 +1,95 @@
package be.digitalia.fosdem.livedata
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import androidx.lifecycle.LiveData
import java.util.*
import java.util.concurrent.TimeUnit
object LiveDataFactory {
private val handler = Handler(Looper.getMainLooper())
fun interval(period: Long, unit: TimeUnit): LiveData<Long> {
return IntervalLiveData(unit.toMillis(period))
}
/**
* Builds a LiveData whose value is true during scheduled periods.
*
* @param startEndTimestamps a list of timestamps in milliseconds, sorted in chronological order.
* Odd and even values represent beginnings and ends of periods, respectively.
*/
fun scheduler(vararg startEndTimestamps: Long): LiveData<Boolean> {
return SchedulerLiveData(startEndTimestamps)
}
private class IntervalLiveData(private val periodInMillis: Long) : LiveData<Long>(), Runnable {
private var updateTime = 0L
private var version = 0L
override fun onActive() {
val now = SystemClock.elapsedRealtime()
if (now >= updateTime) {
update(now)
} else {
handler.postDelayed(this, updateTime - now)
}
}
override fun onInactive() {
handler.removeCallbacks(this)
}
private fun update(now: Long) {
value = version++
updateTime = now + periodInMillis
handler.postDelayed(this, periodInMillis)
}
override fun run() {
update(SystemClock.elapsedRealtime())
}
}
private class SchedulerLiveData(private val startEndTimestamps: LongArray) : LiveData<Boolean>(), Runnable {
private var nowPosition = -1
override fun onActive() {
val now = System.currentTimeMillis()
updateState(now, Arrays.binarySearch(startEndTimestamps, now))
}
override fun onInactive() {
handler.removeCallbacks(this)
}
override fun run() {
val position = nowPosition
updateState(startEndTimestamps[position], position)
}
private fun updateState(now: Long, position: Int) {
var pos = position
val size = startEndTimestamps.size
if (pos >= 0) {
do {
pos++
} while (pos < size && startEndTimestamps[pos] == now)
} else {
pos = pos.inv()
}
val isOn = pos % 2 != 0
if (value != isOn) {
value = isOn
}
if (pos < size) {
nowPosition = pos
handler.postDelayed(this, startEndTimestamps[pos] - now)
}
}
}
}

View file

@ -1,28 +0,0 @@
package be.digitalia.fosdem.livedata;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
/**
* Encapsulates data that can only be consumed once.
*/
public class SingleEvent<T> {
private T content;
public SingleEvent(@NonNull T content) {
this.content = content;
}
/**
* @return The content, or null if it has already been consumed.
*/
@Nullable
public T consume() {
final T previousContent = content;
if (previousContent != null) {
content = null;
}
return previousContent;
}
}

View file

@ -0,0 +1,20 @@
package be.digitalia.fosdem.livedata
/**
* Encapsulates data that can only be consumed once.
*/
class SingleEvent<T>(content: T) {
private var content: T? = content
/**
* @return The content, or null if it has already been consumed.
*/
fun consume(): T? {
val previousContent = content
if (previousContent != null) {
content = null
}
return previousContent
}
}

View file

@ -1,29 +0,0 @@
package be.digitalia.fosdem.model;
import androidx.room.ColumnInfo;
import androidx.room.TypeConverters;
import be.digitalia.fosdem.db.converters.NullableDateTypeConverters;
import java.util.Date;
public class AlarmInfo {
@ColumnInfo(name = "event_id")
private long eventId;
@ColumnInfo(name = "start_time")
@TypeConverters({NullableDateTypeConverters.class})
private Date startTime;
public AlarmInfo(long eventId, Date startTime) {
this.eventId = eventId;
this.startTime = startTime;
}
public long getEventId() {
return eventId;
}
public Date getStartTime() {
return startTime;
}
}

View file

@ -0,0 +1,14 @@
package be.digitalia.fosdem.model
import androidx.room.ColumnInfo
import androidx.room.TypeConverters
import be.digitalia.fosdem.db.converters.NullableDateTypeConverters
import java.util.*
class AlarmInfo(
@ColumnInfo(name = "event_id")
val eventId: Long,
@ColumnInfo(name = "start_time")
@field:TypeConverters(NullableDateTypeConverters::class)
val startTime: Date?
)

Some files were not shown because too many files have changed in this diff Show more