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:
parent
afcc269eda
commit
669f5d1cb0
208 changed files with 7997 additions and 10400 deletions
|
@ -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'
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
21
app/src/main/java/be/digitalia/fosdem/FosdemApplication.kt
Normal file
21
app/src/main/java/be/digitalia/fosdem/FosdemApplication.kt
Normal 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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
395
app/src/main/java/be/digitalia/fosdem/activities/MainActivity.kt
Normal file
395
app/src/main/java/be/digitalia/fosdem/activities/MainActivity.kt
Normal 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"
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
183
app/src/main/java/be/digitalia/fosdem/adapters/ConcatAdapter.kt
Normal file
183
app/src/main/java/be/digitalia/fosdem/adapters/ConcatAdapter.kt
Normal 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)
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
158
app/src/main/java/be/digitalia/fosdem/adapters/EventsAdapter.kt
Normal file
158
app/src/main/java/be/digitalia/fosdem/adapters/EventsAdapter.kt
Normal 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()
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
187
app/src/main/java/be/digitalia/fosdem/api/FosdemApi.kt
Normal file
187
app/src/main/java/be/digitalia/fosdem/api/FosdemApi.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
30
app/src/main/java/be/digitalia/fosdem/api/FosdemUrls.kt
Normal file
30
app/src/main/java/be/digitalia/fosdem/api/FosdemUrls.kt
Normal 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/"
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
79
app/src/main/java/be/digitalia/fosdem/db/AppDatabase.kt
Normal file
79
app/src/main/java/be/digitalia/fosdem/db/AppDatabase.kt
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
|
@ -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);
|
||||
}
|
89
app/src/main/java/be/digitalia/fosdem/db/BookmarksDao.kt
Normal file
89
app/src/main/java/be/digitalia/fosdem/db/BookmarksDao.kt
Normal 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
|
||||
}
|
|
@ -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);
|
||||
}
|
467
app/src/main/java/be/digitalia/fosdem/db/ScheduleDao.kt
Normal file
467
app/src/main/java/be/digitalia/fosdem/db/ScheduleDao.kt
Normal 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"
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
|
@ -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) {
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
|
@ -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?
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
14
app/src/main/java/be/digitalia/fosdem/model/AlarmInfo.kt
Normal file
14
app/src/main/java/be/digitalia/fosdem/model/AlarmInfo.kt
Normal 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
Loading…
Reference in a new issue