diff --git a/app/build.gradle b/app/build.gradle index 7fe4683..d7f47d9 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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' } diff --git a/app/src/main/java/be/digitalia/fosdem/FosdemApplication.java b/app/src/main/java/be/digitalia/fosdem/FosdemApplication.java deleted file mode 100644 index 0eca4ed..0000000 --- a/app/src/main/java/be/digitalia/fosdem/FosdemApplication.java +++ /dev/null @@ -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); - } - -} diff --git a/app/src/main/java/be/digitalia/fosdem/FosdemApplication.kt b/app/src/main/java/be/digitalia/fosdem/FosdemApplication.kt new file mode 100644 index 0000000..f99ac5f --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/FosdemApplication.kt @@ -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) + } + +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/activities/EventDetailsActivity.java b/app/src/main/java/be/digitalia/fosdem/activities/EventDetailsActivity.java deleted file mode 100644 index 0ebc6b4..0000000 --- a/app/src/main/java/be/digitalia/fosdem/activities/EventDetailsActivity.java +++ /dev/null @@ -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, 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); - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/activities/EventDetailsActivity.kt b/app/src/main/java/be/digitalia/fosdem/activities/EventDetailsActivity.kt new file mode 100644 index 0000000..dadf8cb --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/activities/EventDetailsActivity.kt @@ -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(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(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(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" + } +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/activities/ExternalBookmarksActivity.java b/app/src/main/java/be/digitalia/fosdem/activities/ExternalBookmarksActivity.java deleted file mode 100644 index a77f41d..0000000 --- a/app/src/main/java/be/digitalia/fosdem/activities/ExternalBookmarksActivity.java +++ /dev/null @@ -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(); - } - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/activities/ExternalBookmarksActivity.kt b/app/src/main/java/be/digitalia/fosdem/activities/ExternalBookmarksActivity.kt new file mode 100644 index 0000000..f4673e4 --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/activities/ExternalBookmarksActivity.kt @@ -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) } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/activities/MainActivity.java b/app/src/main/java/be/digitalia/fosdem/activities/MainActivity.java deleted file mode 100644 index fd132f6..0000000 --- a/app/src/main/java/be/digitalia/fosdem/activities/MainActivity.java +++ /dev/null @@ -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> 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 lastUpdateTimeObserver = new Observer() { - @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; - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/activities/MainActivity.kt b/app/src/main/java/be/digitalia/fosdem/activities/MainActivity.kt new file mode 100644 index 0000000..a3b145c --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/activities/MainActivity.kt @@ -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" + } +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/activities/PersonInfoActivity.java b/app/src/main/java/be/digitalia/fosdem/activities/PersonInfoActivity.java deleted file mode 100644 index 35c6894..0000000 --- a/app/src/main/java/be/digitalia/fosdem/activities/PersonInfoActivity.java +++ /dev/null @@ -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; - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/activities/PersonInfoActivity.kt b/app/src/main/java/be/digitalia/fosdem/activities/PersonInfoActivity.kt new file mode 100644 index 0000000..01d87c1 --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/activities/PersonInfoActivity.kt @@ -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" + } +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/activities/RoomImageDialogActivity.java b/app/src/main/java/be/digitalia/fosdem/activities/RoomImageDialogActivity.java deleted file mode 100644 index 511ce20..0000000 --- a/app/src/main/java/be/digitalia/fosdem/activities/RoomImageDialogActivity.java +++ /dev/null @@ -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); - } - }); - } - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/activities/RoomImageDialogActivity.kt b/app/src/main/java/be/digitalia/fosdem/activities/RoomImageDialogActivity.kt new file mode 100644 index 0000000..c8cd740 --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/activities/RoomImageDialogActivity.kt @@ -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(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 + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/activities/SearchResultActivity.java b/app/src/main/java/be/digitalia/fosdem/activities/SearchResultActivity.java deleted file mode 100644 index 5eaf079..0000000 --- a/app/src/main/java/be/digitalia/fosdem/activities/SearchResultActivity.java +++ /dev/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; - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/activities/SearchResultActivity.kt b/app/src/main/java/be/digitalia/fosdem/activities/SearchResultActivity.kt new file mode 100644 index 0000000..bad76c5 --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/activities/SearchResultActivity.kt @@ -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" + } +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/activities/SettingsActivity.java b/app/src/main/java/be/digitalia/fosdem/activities/SettingsActivity.java deleted file mode 100644 index 386a617..0000000 --- a/app/src/main/java/be/digitalia/fosdem/activities/SettingsActivity.java +++ /dev/null @@ -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); - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/activities/SettingsActivity.kt b/app/src/main/java/be/digitalia/fosdem/activities/SettingsActivity.kt new file mode 100644 index 0000000..7411aad --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/activities/SettingsActivity.kt @@ -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) + } +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/activities/SimpleToolbarActivity.java b/app/src/main/java/be/digitalia/fosdem/activities/SimpleToolbarActivity.java deleted file mode 100644 index 55a44a7..0000000 --- a/app/src/main/java/be/digitalia/fosdem/activities/SimpleToolbarActivity.java +++ /dev/null @@ -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)); - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/activities/SimpleToolbarActivity.kt b/app/src/main/java/be/digitalia/fosdem/activities/SimpleToolbarActivity.kt new file mode 100644 index 0000000..1ff2d0a --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/activities/SimpleToolbarActivity.kt @@ -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)) + } +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/activities/TrackScheduleActivity.java b/app/src/main/java/be/digitalia/fosdem/activities/TrackScheduleActivity.java deleted file mode 100644 index bbd7274..0000000 --- a/app/src/main/java/be/digitalia/fosdem/activities/TrackScheduleActivity.java +++ /dev/null @@ -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); - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/activities/TrackScheduleActivity.kt b/app/src/main/java/be/digitalia/fosdem/activities/TrackScheduleActivity.kt new file mode 100644 index 0000000..e2efe9e --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/activities/TrackScheduleActivity.kt @@ -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(LazyThreadSafetyMode.NONE) { + intent.getParcelableExtra(EXTRA_DAY)!! + } + private val track by lazy(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(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" + } +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/activities/TrackScheduleEventActivity.java b/app/src/main/java/be/digitalia/fosdem/activities/TrackScheduleEventActivity.java deleted file mode 100644 index d7e22a0..0000000 --- a/app/src/main/java/be/digitalia/fosdem/activities/TrackScheduleEventActivity.java +++ /dev/null @@ -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>, 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 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 events = null; - - TrackScheduleEventAdapter(@NonNull FragmentActivity fragmentActivity) { - super(fragmentActivity); - } - - public void setSchedule(List 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); - } - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/activities/TrackScheduleEventActivity.kt b/app/src/main/java/be/digitalia/fosdem/activities/TrackScheduleEventActivity.kt new file mode 100644 index 0000000..acefbaf --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/activities/TrackScheduleEventActivity.kt @@ -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(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(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(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? = 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" + } +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/adapters/BookmarksAdapter.java b/app/src/main/java/be/digitalia/fosdem/adapters/BookmarksAdapter.java deleted file mode 100644 index 457ace7..0000000 --- a/app/src/main/java/be/digitalia/fosdem/adapters/BookmarksAdapter.java +++ /dev/null @@ -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 - implements Observer> { - - private static final DiffUtil.ItemCallback DIFF_CALLBACK = new SimpleItemCallback() { - @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 observers = new SimpleArrayMap<>(); - final MultiChoiceHelper multiChoiceHelper; - private Map 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 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 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); - } - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/adapters/BookmarksAdapter.kt b/app/src/main/java/be/digitalia/fosdem/adapters/BookmarksAdapter.kt new file mode 100644 index 0000000..e8cc022 --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/adapters/BookmarksAdapter.kt @@ -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(DIFF_CALLBACK) { + + private val timeDateFormat = DateUtils.getTimeDateFormat(activity) + @ColorInt + private val errorColor: Int + private val observers = SimpleArrayMap() + private var roomStatuses: Map? = 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) { + 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 { oldItem, newItem -> + oldItem.id == newItem.id + } + private val DETAILS_PAYLOAD = Any() + } +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/adapters/ConcatAdapter.java b/app/src/main/java/be/digitalia/fosdem/adapters/ConcatAdapter.java deleted file mode 100644 index c856c01..0000000 --- a/app/src/main/java/be/digitalia/fosdem/adapters/ConcatAdapter.java +++ /dev/null @@ -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. - *

- * 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 { - - private final RecyclerView.Adapter[] adapters; - private final RecyclerView.AdapterDataObserver[] adapterObservers; - final int[] offsets; - int totalItemCount = -1; - private final SparseArray> 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... adapters) { - this.adapters = (RecyclerView.Adapter[]) 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 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 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 adapter : adapters) { - adapter.onAttachedToRecyclerView(recyclerView); - } - } - - @Override - public void onDetachedFromRecyclerView(@NonNull RecyclerView recyclerView) { - for (RecyclerView.Adapter 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); - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/adapters/ConcatAdapter.kt b/app/src/main/java/be/digitalia/fosdem/adapters/ConcatAdapter.kt new file mode 100644 index 0000000..052cdd4 --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/adapters/ConcatAdapter.kt @@ -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() { + + @Suppress("UNCHECKED_CAST") + private val adapters = adapters as Array> + private val adapterObservers = Array(adapters.size) { InternalObserver(it) } + private val offsets = IntArray(adapters.size) + private var totalItemCount = -1 + private val viewTypeAdapters = SparseArray>() + + 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) { + 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) + } +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/adapters/EventsAdapter.java b/app/src/main/java/be/digitalia/fosdem/adapters/EventsAdapter.java deleted file mode 100644 index 9d1dae5..0000000 --- a/app/src/main/java/be/digitalia/fosdem/adapters/EventsAdapter.java +++ /dev/null @@ -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 - implements Observer> { - - private static final DiffUtil.ItemCallback DIFF_CALLBACK = new SimpleItemCallback() { - @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 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 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 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); - } - } - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/adapters/EventsAdapter.kt b/app/src/main/java/be/digitalia/fosdem/adapters/EventsAdapter.kt new file mode 100644 index 0000000..902d5b3 --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/adapters/EventsAdapter.kt @@ -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(DIFF_CALLBACK) { + + private val timeDateFormat = DateUtils.getTimeDateFormat(context) + private var roomStatuses: Map? = 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) { + 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 { oldItem, newItem -> + oldItem.event.id == newItem.event.id + } + private val DETAILS_PAYLOAD = Any() + } +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/adapters/SimpleItemCallback.java b/app/src/main/java/be/digitalia/fosdem/adapters/SimpleItemCallback.java deleted file mode 100644 index e0499b7..0000000 --- a/app/src/main/java/be/digitalia/fosdem/adapters/SimpleItemCallback.java +++ /dev/null @@ -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 extends DiffUtil.ItemCallback { - @Override - public boolean areItemsTheSame(@NonNull T oldItem, @NonNull T newItem) { - return ObjectsCompat.equals(oldItem, newItem); - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/adapters/SimpleItemCallback.kt b/app/src/main/java/be/digitalia/fosdem/adapters/SimpleItemCallback.kt new file mode 100644 index 0000000..12129a6 --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/adapters/SimpleItemCallback.kt @@ -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 createSimpleItemCallback(crossinline areItemsTheSame: (oldItem: T, newItem: T) -> Boolean): DiffUtil.ItemCallback { + return object : DiffUtil.ItemCallback() { + 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 + } + } +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/adapters/TrackScheduleAdapter.java b/app/src/main/java/be/digitalia/fosdem/adapters/TrackScheduleAdapter.java deleted file mode 100644 index 2267cd8..0000000 --- a/app/src/main/java/be/digitalia/fosdem/adapters/TrackScheduleAdapter.java +++ /dev/null @@ -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 { - - public interface EventClickListener { - void onEventClick(int position, Event event); - } - - private static final DiffUtil.ItemCallback DIFF_CALLBACK = new SimpleItemCallback() { - @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 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); - } - } - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/adapters/TrackScheduleAdapter.kt b/app/src/main/java/be/digitalia/fosdem/adapters/TrackScheduleAdapter.kt new file mode 100644 index 0000000..d48b975 --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/adapters/TrackScheduleAdapter.kt @@ -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(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) { + 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() + } +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/alarms/FosdemAlarmManager.java b/app/src/main/java/be/digitalia/fosdem/alarms/FosdemAlarmManager.java deleted file mode 100644 index 8bc0e9e..0000000 --- a/app/src/main/java/be/digitalia/fosdem/alarms/FosdemAlarmManager.java +++ /dev/null @@ -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); - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/alarms/FosdemAlarmManager.kt b/app/src/main/java/be/digitalia/fosdem/alarms/FosdemAlarmManager.kt new file mode 100644 index 0000000..41522de --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/alarms/FosdemAlarmManager.kt @@ -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) + } +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/api/FosdemApi.java b/app/src/main/java/be/digitalia/fosdem/api/FosdemApi.java deleted file mode 100644 index 67e4b62..0000000 --- a/app/src/main/java/be/digitalia/fosdem/api/FosdemApi.java +++ /dev/null @@ -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 progress = new MutableLiveData<>(); - private static final MutableLiveData> result = new MutableLiveData<>(); - private static LiveData> 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 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 getDownloadScheduleProgress() { - return progress; - } - - public static LiveData> getDownloadScheduleResult() { - return result; - } - - @MainThread - public static LiveData> 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> daysLiveData = AppDatabase.getInstance(context).getScheduleDao().getDays(); - final LiveData 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> liveRoomStatuses = new LiveRoomStatusesLiveData(); - final LiveData> 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; - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/api/FosdemApi.kt b/app/src/main/java/be/digitalia/fosdem/api/FosdemApi.kt new file mode 100644 index 0000000..af3a3a8 --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/api/FosdemApi.kt @@ -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() + private val _downloadScheduleResult = MutableLiveData>() + private var roomStatuses: LiveData>? = 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 + get() = _downloadScheduleProgress + + val downloadScheduleResult: LiveData> + get() = _downloadScheduleResult + + @MainThread + fun getRoomStatuses(context: Context): LiveData> { + 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()) + 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> { + 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 + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/api/FosdemUrls.java b/app/src/main/java/be/digitalia/fosdem/api/FosdemUrls.java deleted file mode 100644 index 9d87c66..0000000 --- a/app/src/main/java/be/digitalia/fosdem/api/FosdemUrls.java +++ /dev/null @@ -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; - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/api/FosdemUrls.kt b/app/src/main/java/be/digitalia/fosdem/api/FosdemUrls.kt new file mode 100644 index 0000000..20d7630 --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/api/FosdemUrls.kt @@ -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/" + } +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/api/LiveRoomStatusesLiveData.java b/app/src/main/java/be/digitalia/fosdem/api/LiveRoomStatusesLiveData.java deleted file mode 100644 index 38a3013..0000000 --- a/app/src/main/java/be/digitalia/fosdem/api/LiveRoomStatusesLiveData.java +++ /dev/null @@ -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> { - - 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> 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>() { - - @Override - protected Map 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 result) { - currentTask = null; - if (result != null) { - onSuccess(result); - } else { - onError(); - } - } - }; - currentTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); - } - - void onSuccess(Map 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; - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/db/AppDatabase.java b/app/src/main/java/be/digitalia/fosdem/db/AppDatabase.java deleted file mode 100644 index 1929d88..0000000 --- a/app/src/main/java/be/digitalia/fosdem/db/AppDatabase.java +++ /dev/null @@ -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(); -} diff --git a/app/src/main/java/be/digitalia/fosdem/db/AppDatabase.kt b/app/src/main/java/be/digitalia/fosdem/db/AppDatabase.kt new file mode 100644 index 0000000..0ad473c --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/db/AppDatabase.kt @@ -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({ 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) + } + }) +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/db/BookmarksDao.java b/app/src/main/java/be/digitalia/fosdem/db/BookmarksDao.java deleted file mode 100644 index acd4f6c..0000000 --- a/app/src/main/java/be/digitalia/fosdem/db/BookmarksDao.java +++ /dev/null @@ -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> 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 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); -} diff --git a/app/src/main/java/be/digitalia/fosdem/db/BookmarksDao.kt b/app/src/main/java/be/digitalia/fosdem/db/BookmarksDao.kt new file mode 100644 index 0000000..ee0c2a1 --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/db/BookmarksDao.kt @@ -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> + + @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 + + @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 + + @Query("SELECT COUNT(*) FROM bookmarks WHERE event_id = :event") + abstract fun getBookmarkStatus(event: Event): LiveData + + 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 +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/db/ScheduleDao.java b/app/src/main/java/be/digitalia/fosdem/db/ScheduleDao.java deleted file mode 100644 index 1958b98..0000000 --- a/app/src/main/java/be/digitalia/fosdem/db/ScheduleDao.java +++ /dev/null @@ -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 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 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 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 events, String lastModifiedTag) { - // 1: Delete the previous schedule - clearSchedule(); - - // 2: Insert the events - int totalEvents = 0; - final Map tracks = new HashMap<>(); - long nextTrackId = 0L; - long minEventId = Long.MAX_VALUE; - final Set 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 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 persons); - - @Insert - protected abstract void insertEventsToPersons(EventToPerson[] eventsToPersons); - - @Insert - protected abstract void insertLinks(List links); - - @Insert - protected abstract void insertDays(Set 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> daysLiveData; - - public LiveData> 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> getDaysInternal(); - - @WorkerThread - public int getYear() { - long date = 0L; - - // Compute from cached days if available - final LiveData> cache = daysLiveData; - List 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> 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 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> 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 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 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 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 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 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 getPersons(); - - public LiveData getEventDetails(final Event event) { - final MutableLiveData 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 getPersons(Event event); - - @Query("SELECT * FROM links WHERE event_id = :event ORDER BY id ASC") - protected abstract List getLinks(Event event); -} diff --git a/app/src/main/java/be/digitalia/fosdem/db/ScheduleDao.kt b/app/src/main/java/be/digitalia/fosdem/db/ScheduleDao.kt new file mode 100644 index 0000000..b76dd09 --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/db/ScheduleDao.kt @@ -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() + + /** + * @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 + @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, 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, lastModifiedTag: String?): Int { + // 1: Delete the previous schedule + clearSchedule() + + // 2: Insert the events + var totalEvents = 0 + val tracks = mutableMapOf() + var nextTrackId = 0L + var minEventId = Long.MAX_VALUE + val days: MutableSet = 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) + + @Insert + protected abstract fun insertEventsToPersons(eventsToPersons: Array) + + @Insert + protected abstract fun insertLinks(links: List) + + @Insert + protected abstract fun insertDays(days: Set) + + @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> by daysLiveDataDelegate + + @Query("SELECT `index`, date FROM days ORDER BY `index` ASC") + protected abstract fun getDaysInternal(): LiveData> + + @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> + + /** + * 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 + + /** + * 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> + + /** + * 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 + + /** + * 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 + + /** + * 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 + + /** + * 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 + + /** + * 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 + + /** + * 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 + + fun getEventDetails(event: Event): LiveData { + 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 + + @Query("SELECT * FROM links WHERE event_id = :event ORDER BY id ASC") + protected abstract suspend fun getLinks(event: Event?): List + + companion object { + private const val LAST_UPDATE_TIME_PREF = "last_update_time" + private const val LAST_MODIFIED_TAG_PREF = "last_modified_tag" + } +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/db/converters/GlobalTypeConverters.java b/app/src/main/java/be/digitalia/fosdem/db/converters/GlobalTypeConverters.java deleted file mode 100644 index ab46623..0000000 --- a/app/src/main/java/be/digitalia/fosdem/db/converters/GlobalTypeConverters.java +++ /dev/null @@ -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(); - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/db/converters/GlobalTypeConverters.kt b/app/src/main/java/be/digitalia/fosdem/db/converters/GlobalTypeConverters.kt new file mode 100644 index 0000000..2767fcc --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/db/converters/GlobalTypeConverters.kt @@ -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 +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/db/converters/NonNullDateTypeConverters.java b/app/src/main/java/be/digitalia/fosdem/db/converters/NonNullDateTypeConverters.java deleted file mode 100644 index 4949e63..0000000 --- a/app/src/main/java/be/digitalia/fosdem/db/converters/NonNullDateTypeConverters.java +++ /dev/null @@ -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(); - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/db/converters/NonNullDateTypeConverters.kt b/app/src/main/java/be/digitalia/fosdem/db/converters/NonNullDateTypeConverters.kt new file mode 100644 index 0000000..2df6e71 --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/db/converters/NonNullDateTypeConverters.kt @@ -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 +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/db/converters/NullableDateTypeConverters.java b/app/src/main/java/be/digitalia/fosdem/db/converters/NullableDateTypeConverters.java deleted file mode 100644 index e460461..0000000 --- a/app/src/main/java/be/digitalia/fosdem/db/converters/NullableDateTypeConverters.java +++ /dev/null @@ -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(); - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/db/converters/NullableDateTypeConverters.kt b/app/src/main/java/be/digitalia/fosdem/db/converters/NullableDateTypeConverters.kt new file mode 100644 index 0000000..944601a --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/db/converters/NullableDateTypeConverters.kt @@ -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 +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/db/entities/Bookmark.java b/app/src/main/java/be/digitalia/fosdem/db/entities/Bookmark.java deleted file mode 100644 index e60f07a..0000000 --- a/app/src/main/java/be/digitalia/fosdem/db/entities/Bookmark.java +++ /dev/null @@ -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; - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/db/entities/Bookmark.kt b/app/src/main/java/be/digitalia/fosdem/db/entities/Bookmark.kt new file mode 100644 index 0000000..49a08e6 --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/db/entities/Bookmark.kt @@ -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" + } +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/db/entities/EventEntity.java b/app/src/main/java/be/digitalia/fosdem/db/entities/EventEntity.java deleted file mode 100644 index b7d0f52..0000000 --- a/app/src/main/java/be/digitalia/fosdem/db/entities/EventEntity.java +++ /dev/null @@ -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; - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/db/entities/EventEntity.kt b/app/src/main/java/be/digitalia/fosdem/db/entities/EventEntity.kt new file mode 100644 index 0000000..67f386d --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/db/entities/EventEntity.kt @@ -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" + } +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/db/entities/EventTitles.java b/app/src/main/java/be/digitalia/fosdem/db/entities/EventTitles.java deleted file mode 100644 index e7cc770..0000000 --- a/app/src/main/java/be/digitalia/fosdem/db/entities/EventTitles.java +++ /dev/null @@ -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; - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/db/entities/EventTitles.kt b/app/src/main/java/be/digitalia/fosdem/db/entities/EventTitles.kt new file mode 100644 index 0000000..51d72bd --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/db/entities/EventTitles.kt @@ -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" + } +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/db/entities/EventToPerson.java b/app/src/main/java/be/digitalia/fosdem/db/entities/EventToPerson.java deleted file mode 100644 index 278612f..0000000 --- a/app/src/main/java/be/digitalia/fosdem/db/entities/EventToPerson.java +++ /dev/null @@ -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; - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/db/entities/EventToPerson.kt b/app/src/main/java/be/digitalia/fosdem/db/entities/EventToPerson.kt new file mode 100644 index 0000000..d6ad693 --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/db/entities/EventToPerson.kt @@ -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" + } +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/fragments/BaseLiveListFragment.java b/app/src/main/java/be/digitalia/fosdem/fragments/BaseLiveListFragment.java deleted file mode 100644 index 5b55bba..0000000 --- a/app/src/main/java/be/digitalia/fosdem/fragments/BaseLiveListFragment.java +++ /dev/null @@ -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> { - - 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 events) { - adapter.submitList(events, preserveScrollPositionRunnable); - setProgressBarVisible(false); - } - - protected abstract String getEmptyText(); - - @NonNull - protected abstract LiveData> getDataSource(@NonNull LiveViewModel viewModel); -} diff --git a/app/src/main/java/be/digitalia/fosdem/fragments/BookmarksListFragment.java b/app/src/main/java/be/digitalia/fosdem/fragments/BookmarksListFragment.java deleted file mode 100644 index 38d3906..0000000 --- a/app/src/main/java/be/digitalia/fosdem/fragments/BookmarksListFragment.java +++ /dev/null @@ -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>, 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 bookmarks) { - adapter.submitList(bookmarks); - setProgressBarVisible(false); - } - - @Nullable - @Override - public NdefRecord createNfcAppData() { - Context context = getContext(); - List bookmarks = (viewModel == null) ? null : viewModel.getBookmarks().getValue(); - if (context == null || bookmarks == null || bookmarks.size() == 0) { - return null; - } - return NfcUtils.createBookmarksAppData(context, bookmarks); - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/fragments/BookmarksListFragment.kt b/app/src/main/java/be/digitalia/fosdem/fragments/BookmarksListFragment.kt new file mode 100644 index 0000000..d8c9a82 --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/fragments/BookmarksListFragment.kt @@ -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" + } +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/fragments/EventDetailsFragment.java b/app/src/main/java/be/digitalia/fosdem/fragments/EventDetailsFragment.java deleted file mode 100644 index 979de49..0000000 --- a/app/src/main/java/be/digitalia/fosdem/fragments/EventDetailsFragment.java +++ /dev/null @@ -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 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 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) { - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/fragments/EventDetailsFragment.kt b/app/src/main/java/be/digitalia/fosdem/fragments/EventDetailsFragment.kt new file mode 100644 index 0000000..7d6e7ea --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/fragments/EventDetailsFragment.kt @@ -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(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(R.id.title).text = event.title + view.findViewById(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(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(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(R.id.abstract_text).apply { + val abstractText = event.abstractText + if (abstractText.isNullOrEmpty()) { + isVisible = false + } else { + text = abstractText.parseHtml(resources) + movementMethod = ClickableArrowKeyMovementMethod + } + } + + view.findViewById(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(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) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/fragments/ExternalBookmarksListFragment.java b/app/src/main/java/be/digitalia/fosdem/fragments/ExternalBookmarksListFragment.java deleted file mode 100644 index c6922d8..0000000 --- a/app/src/main/java/be/digitalia/fosdem/fragments/ExternalBookmarksListFragment.java +++ /dev/null @@ -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> { - - 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 bookmarks) { - adapter.submitList(bookmarks); - setProgressBarVisible(false); - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/fragments/ExternalBookmarksListFragment.kt b/app/src/main/java/be/digitalia/fosdem/fragments/ExternalBookmarksListFragment.kt new file mode 100644 index 0000000..32e9f7e --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/fragments/ExternalBookmarksListFragment.kt @@ -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) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/fragments/LiveFragment.java b/app/src/main/java/be/digitalia/fosdem/fragments/LiveFragment.java deleted file mode 100644 index f4f331b..0000000 --- a/app/src/main/java/be/digitalia/fosdem/fragments/LiveFragment.java +++ /dev/null @@ -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; - } - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/fragments/LiveFragment.kt b/app/src/main/java/be/digitalia/fosdem/fragments/LiveFragment.kt new file mode 100644 index 0000000..b6267df --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/fragments/LiveFragment.kt @@ -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 + } + } +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/fragments/LiveListFragment.kt b/app/src/main/java/be/digitalia/fosdem/fragments/LiveListFragment.kt new file mode 100644 index 0000000..94fd5da --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/fragments/LiveListFragment.kt @@ -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>) + : 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) \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/fragments/MapFragment.java b/app/src/main/java/be/digitalia/fosdem/fragments/MapFragment.java deleted file mode 100644 index 1b7a270..0000000 --- a/app/src/main/java/be/digitalia/fosdem/fragments/MapFragment.java +++ /dev/null @@ -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) { - } - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/fragments/MapFragment.kt b/app/src/main/java/be/digitalia/fosdem/fragments/MapFragment.kt new file mode 100644 index 0000000..58a97ed --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/fragments/MapFragment.kt @@ -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(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 + } +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/fragments/NextLiveListFragment.java b/app/src/main/java/be/digitalia/fosdem/fragments/NextLiveListFragment.java deleted file mode 100644 index b14fb44..0000000 --- a/app/src/main/java/be/digitalia/fosdem/fragments/NextLiveListFragment.java +++ /dev/null @@ -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> getDataSource(@NonNull LiveViewModel viewModel) { - return viewModel.getNextEvents(); - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/fragments/NowLiveListFragment.java b/app/src/main/java/be/digitalia/fosdem/fragments/NowLiveListFragment.java deleted file mode 100644 index 18cc70d..0000000 --- a/app/src/main/java/be/digitalia/fosdem/fragments/NowLiveListFragment.java +++ /dev/null @@ -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> getDataSource(@NonNull LiveViewModel viewModel) { - return viewModel.getEventsInProgress(); - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/fragments/PersonInfoListFragment.java b/app/src/main/java/be/digitalia/fosdem/fragments/PersonInfoListFragment.java deleted file mode 100644 index a9195ce..0000000 --- a/app/src/main/java/be/digitalia/fosdem/fragments/PersonInfoListFragment.java +++ /dev/null @@ -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> { - - 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 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 events) { - adapter.submitList(events); - setProgressBarVisible(false); - } - - static class HeaderAdapter extends RecyclerView.Adapter { - - @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); - } - } - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/fragments/PersonInfoListFragment.kt b/app/src/main/java/be/digitalia/fosdem/fragments/PersonInfoListFragment.kt new file mode 100644 index 0000000..5dafc2b --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/fragments/PersonInfoListFragment.kt @@ -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(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() { + + 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) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/fragments/PersonsListFragment.java b/app/src/main/java/be/digitalia/fosdem/fragments/PersonsListFragment.java deleted file mode 100644 index 8f8dcfa..0000000 --- a/app/src/main/java/be/digitalia/fosdem/fragments/PersonsListFragment.java +++ /dev/null @@ -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> { - - 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 persons) { - adapter.submitList(persons); - setProgressBarVisible(false); - } - - private static class PersonsAdapter extends PagedListAdapter { - - private static final DiffUtil.ItemCallback DIFF_CALLBACK = new SimpleItemCallback() { - @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); - } - } - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/fragments/PersonsListFragment.kt b/app/src/main/java/be/digitalia/fosdem/fragments/PersonsListFragment.kt new file mode 100644 index 0000000..5d5b8b2 --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/fragments/PersonsListFragment.kt @@ -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(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 { 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) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/fragments/RecycledViewPoolProvider.java b/app/src/main/java/be/digitalia/fosdem/fragments/RecycledViewPoolProvider.java deleted file mode 100644 index 51b432f..0000000 --- a/app/src/main/java/be/digitalia/fosdem/fragments/RecycledViewPoolProvider.java +++ /dev/null @@ -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(); -} diff --git a/app/src/main/java/be/digitalia/fosdem/fragments/RecycledViewPoolProvider.kt b/app/src/main/java/be/digitalia/fosdem/fragments/RecycledViewPoolProvider.kt new file mode 100644 index 0000000..dda80bd --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/fragments/RecycledViewPoolProvider.kt @@ -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? +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/fragments/RecyclerViewFragment.java b/app/src/main/java/be/digitalia/fosdem/fragments/RecyclerViewFragment.java deleted file mode 100644 index a2635c9..0000000 --- a/app/src/main/java/be/digitalia/fosdem/fragments/RecyclerViewFragment.java +++ /dev/null @@ -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(); - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/fragments/RecyclerViewFragment.kt b/app/src/main/java/be/digitalia/fosdem/fragments/RecyclerViewFragment.kt new file mode 100644 index 0000000..1bcd3a3 --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/fragments/RecyclerViewFragment.kt @@ -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 + } +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/fragments/RoomImageDialogFragment.java b/app/src/main/java/be/digitalia/fosdem/fragments/RoomImageDialogFragment.java deleted file mode 100644 index 6b24435..0000000 --- a/app/src/main/java/be/digitalia/fosdem/fragments/RoomImageDialogFragment.java +++ /dev/null @@ -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); - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/fragments/RoomImageDialogFragment.kt b/app/src/main/java/be/digitalia/fosdem/fragments/RoomImageDialogFragment.kt new file mode 100644 index 0000000..4eff669 --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/fragments/RoomImageDialogFragment.kt @@ -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(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) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/fragments/SearchResultListFragment.java b/app/src/main/java/be/digitalia/fosdem/fragments/SearchResultListFragment.java deleted file mode 100644 index 4062995..0000000 --- a/app/src/main/java/be/digitalia/fosdem/fragments/SearchResultListFragment.java +++ /dev/null @@ -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> { - - 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 results) { - adapter.submitList(results); - setProgressBarVisible(false); - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/fragments/SearchResultListFragment.kt b/app/src/main/java/be/digitalia/fosdem/fragments/SearchResultListFragment.kt new file mode 100644 index 0000000..7f80bfb --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/fragments/SearchResultListFragment.kt @@ -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() + } +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/fragments/SettingsFragment.java b/app/src/main/java/be/digitalia/fosdem/fragments/SettingsFragment.java deleted file mode 100644 index 81dd9ec..0000000 --- a/app/src/main/java/be/digitalia/fosdem/fragments/SettingsFragment.java +++ /dev/null @@ -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); - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/fragments/SettingsFragment.kt b/app/src/main/java/be/digitalia/fosdem/fragments/SettingsFragment.kt new file mode 100644 index 0000000..2b309ec --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/fragments/SettingsFragment.kt @@ -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(PreferenceKeys.NOTIFICATIONS_CHANNEL)?.onPreferenceClickListener = Preference.OnPreferenceClickListener { + AlarmIntentService.startChannelNotificationSettingsActivity(requireContext()) + true + } + } + + private fun setupAboutDialog() { + findPreference(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(android.R.id.message).movementMethod = LinkMovementMethod.getInstance() + } + } + + private fun populateVersion() { + findPreference(PreferenceKeys.VERSION)?.summary = BuildConfig.VERSION_NAME + } +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/fragments/TrackScheduleListFragment.java b/app/src/main/java/be/digitalia/fosdem/fragments/TrackScheduleListFragment.java deleted file mode 100644 index bb481d3..0000000 --- a/app/src/main/java/be/digitalia/fosdem/fragments/TrackScheduleListFragment.java +++ /dev/null @@ -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> { - - /** - * 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 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); - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/fragments/TrackScheduleListFragment.kt b/app/src/main/java/be/digitalia/fosdem/fragments/TrackScheduleListFragment.kt new file mode 100644 index 0000000..431b29f --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/fragments/TrackScheduleListFragment.kt @@ -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) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/fragments/TracksFragment.java b/app/src/main/java/be/digitalia/fosdem/fragments/TracksFragment.java deleted file mode 100644 index c247e26..0000000 --- a/app/src/main/java/be/digitalia/fosdem/fragments/TracksFragment.java +++ /dev/null @@ -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> { - - 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 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 days; - - DaysAdapter(Fragment fragment) { - super(fragment.getChildFragmentManager(), fragment.getViewLifecycleOwner().getLifecycle()); - } - - void setDays(List 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(); - } - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/fragments/TracksFragment.kt b/app/src/main/java/be/digitalia/fosdem/fragments/TracksFragment.kt new file mode 100644 index 0000000..6fb362f --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/fragments/TracksFragment.kt @@ -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? = 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" + } +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/fragments/TracksListFragment.java b/app/src/main/java/be/digitalia/fosdem/fragments/TracksListFragment.java deleted file mode 100644 index 5eefb53..0000000 --- a/app/src/main/java/be/digitalia/fosdem/fragments/TracksListFragment.java +++ /dev/null @@ -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> { - - 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 tracks) { - adapter.submitList(tracks); - setProgressBarVisible(false); - } - - private static class TracksAdapter extends ListAdapter { - - private static final DiffUtil.ItemCallback DIFF_CALLBACK = new SimpleItemCallback() { - @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); - } - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/fragments/TracksListFragment.kt b/app/src/main/java/be/digitalia/fosdem/fragments/TracksListFragment.kt new file mode 100644 index 0000000..cda2e27 --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/fragments/TracksListFragment.kt @@ -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(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(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() { + 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) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/livedata/LiveDataFactory.java b/app/src/main/java/be/digitalia/fosdem/livedata/LiveDataFactory.java deleted file mode 100644 index 9d1ff4f..0000000 --- a/app/src/main/java/be/digitalia/fosdem/livedata/LiveDataFactory.java +++ /dev/null @@ -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 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 interval(long period, @NonNull TimeUnit unit) { - return new IntervalLiveData(unit.toMillis(period)); - } - - private static class SchedulerLiveData extends LiveData 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 scheduler(long... startEndTimestamps) { - return new SchedulerLiveData(startEndTimestamps); - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/livedata/LiveDataFactory.kt b/app/src/main/java/be/digitalia/fosdem/livedata/LiveDataFactory.kt new file mode 100644 index 0000000..6d38061 --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/livedata/LiveDataFactory.kt @@ -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 { + 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 { + return SchedulerLiveData(startEndTimestamps) + } + + private class IntervalLiveData(private val periodInMillis: Long) : LiveData(), 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(), 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) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/livedata/SingleEvent.java b/app/src/main/java/be/digitalia/fosdem/livedata/SingleEvent.java deleted file mode 100644 index 53c5884..0000000 --- a/app/src/main/java/be/digitalia/fosdem/livedata/SingleEvent.java +++ /dev/null @@ -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 { - - 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; - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/livedata/SingleEvent.kt b/app/src/main/java/be/digitalia/fosdem/livedata/SingleEvent.kt new file mode 100644 index 0000000..ccc459c --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/livedata/SingleEvent.kt @@ -0,0 +1,20 @@ +package be.digitalia.fosdem.livedata + +/** + * Encapsulates data that can only be consumed once. + */ +class SingleEvent(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 + } +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/model/AlarmInfo.java b/app/src/main/java/be/digitalia/fosdem/model/AlarmInfo.java deleted file mode 100644 index c400221..0000000 --- a/app/src/main/java/be/digitalia/fosdem/model/AlarmInfo.java +++ /dev/null @@ -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; - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/model/AlarmInfo.kt b/app/src/main/java/be/digitalia/fosdem/model/AlarmInfo.kt new file mode 100644 index 0000000..5ca0abb --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/model/AlarmInfo.kt @@ -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? +) \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/model/BookmarkStatus.java b/app/src/main/java/be/digitalia/fosdem/model/BookmarkStatus.java deleted file mode 100644 index 99a7528..0000000 --- a/app/src/main/java/be/digitalia/fosdem/model/BookmarkStatus.java +++ /dev/null @@ -1,20 +0,0 @@ -package be.digitalia.fosdem.model; - -public class BookmarkStatus { - - private final boolean isBookmarked; - private final boolean isUpdate; - - public BookmarkStatus(boolean isBookmarked, boolean isUpdate) { - this.isBookmarked = isBookmarked; - this.isUpdate = isUpdate; - } - - public boolean isBookmarked() { - return isBookmarked; - } - - public boolean isUpdate() { - return isUpdate; - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/model/BookmarkStatus.kt b/app/src/main/java/be/digitalia/fosdem/model/BookmarkStatus.kt new file mode 100644 index 0000000..aba089e --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/model/BookmarkStatus.kt @@ -0,0 +1,3 @@ +package be.digitalia.fosdem.model + +class BookmarkStatus(val isBookmarked: Boolean, val isUpdate: Boolean) \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/model/Building.java b/app/src/main/java/be/digitalia/fosdem/model/Building.java deleted file mode 100644 index 9c0717e..0000000 --- a/app/src/main/java/be/digitalia/fosdem/model/Building.java +++ /dev/null @@ -1,36 +0,0 @@ -package be.digitalia.fosdem.model; - -import android.text.TextUtils; - -public enum Building { - J, K, H, U, AW, Unknown; - - public static Building fromRoomName(String roomName) { - if (!TextUtils.isEmpty(roomName)) { - switch (Character.toUpperCase(roomName.charAt(0))) { - case 'J': - return J; - case 'K': - return K; - case 'H': - return H; - case 'U': - return U; - } - if (roomName.regionMatches(true, 0, "AW", 0, 2)) { - return AW; - } - if ("Janson".equalsIgnoreCase(roomName)) { - return J; - } - if ("Ferrer".equalsIgnoreCase(roomName)) { - return H; - } - if ("Chavanne".equalsIgnoreCase(roomName) || "Lameere".equalsIgnoreCase(roomName) || "Guillissen".equalsIgnoreCase(roomName)) { - return U; - } - } - - return Unknown; - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/model/Building.kt b/app/src/main/java/be/digitalia/fosdem/model/Building.kt new file mode 100644 index 0000000..c9e20ce --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/model/Building.kt @@ -0,0 +1,26 @@ +package be.digitalia.fosdem.model + +enum class Building { + J, K, H, U, AW, Unknown; + + companion object { + fun fromRoomName(roomName: String): Building { + if (roomName.isNotEmpty()) { + when { + roomName.startsWith('J', ignoreCase = true) -> return J + roomName.startsWith('K', ignoreCase = true) -> return K + roomName.startsWith('H', ignoreCase = true) -> return H + roomName.startsWith('U', ignoreCase = true) -> return U + roomName.startsWith("AW", ignoreCase = true) -> return AW + roomName.equals("Janson", ignoreCase = true) -> return J + roomName.equals("Ferrer", ignoreCase = true) -> return H + roomName.equals("Chavanne", ignoreCase = true) + || roomName.equals("Lameere", ignoreCase = true) + || roomName.equals("Guillissen", ignoreCase = true) -> return U + } + } + + return Unknown + } + } +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/model/Day.java b/app/src/main/java/be/digitalia/fosdem/model/Day.java deleted file mode 100644 index a87b23a..0000000 --- a/app/src/main/java/be/digitalia/fosdem/model/Day.java +++ /dev/null @@ -1,109 +0,0 @@ -package be.digitalia.fosdem.model; - -import android.os.Parcel; -import android.os.Parcelable; -import androidx.annotation.NonNull; -import androidx.room.Entity; -import androidx.room.PrimaryKey; -import androidx.room.TypeConverters; -import be.digitalia.fosdem.db.converters.NonNullDateTypeConverters; -import be.digitalia.fosdem.utils.DateUtils; - -import java.text.DateFormat; -import java.text.SimpleDateFormat; -import java.util.Date; -import java.util.Locale; - -@Entity(tableName = Day.TABLE_NAME) -public class Day implements Comparable, Parcelable { - - public static final String TABLE_NAME = "days"; - - private static final DateFormat DAY_DATE_FORMAT = DateUtils.withBelgiumTimeZone(new SimpleDateFormat("EEEE", Locale.US)); - - @PrimaryKey - private int index; - @TypeConverters({NonNullDateTypeConverters.class}) - @NonNull - private Date date; - - public Day() { - } - - public int getIndex() { - return index; - } - - public void setIndex(int index) { - this.index = index; - } - - @NonNull - public Date getDate() { - return date; - } - - public void setDate(@NonNull Date date) { - this.date = date; - } - - public String getName() { - return String.format(Locale.US, "Day %1$d (%2$s)", index, DAY_DATE_FORMAT.format(date)); - } - - public String getShortName() { - return DAY_DATE_FORMAT.format(date); - } - - @NonNull - @Override - public String toString() { - return getName(); - } - - @Override - public int hashCode() { - return index; - } - - @Override - public boolean equals(Object obj) { - if (this == obj) - return true; - if (obj == null) - return false; - Day other = (Day) obj; - return (index == other.index); - } - - @Override - public int compareTo(@NonNull Day other) { - return index - other.index; - } - - @Override - public int describeContents() { - return 0; - } - - @Override - public void writeToParcel(Parcel out, int flags) { - out.writeInt(index); - out.writeLong(date.getTime()); - } - - public static final Parcelable.Creator CREATOR = new Parcelable.Creator() { - public Day createFromParcel(Parcel in) { - return new Day(in); - } - - public Day[] newArray(int size) { - return new Day[size]; - } - }; - - Day(Parcel in) { - index = in.readInt(); - date = new Date(in.readLong()); - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/model/Day.kt b/app/src/main/java/be/digitalia/fosdem/model/Day.kt new file mode 100644 index 0000000..5455d81 --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/model/Day.kt @@ -0,0 +1,41 @@ +package be.digitalia.fosdem.model + +import android.os.Parcelable +import androidx.room.Entity +import androidx.room.PrimaryKey +import androidx.room.TypeConverters +import be.digitalia.fosdem.db.converters.NonNullDateTypeConverters +import be.digitalia.fosdem.utils.DateParceler +import be.digitalia.fosdem.utils.DateUtils.withBelgiumTimeZone +import kotlinx.android.parcel.Parcelize +import kotlinx.android.parcel.WriteWith +import java.text.SimpleDateFormat +import java.util.* + +@Entity(tableName = Day.TABLE_NAME) +@Parcelize +data class Day( + @PrimaryKey + val index: Int, + @field:TypeConverters(NonNullDateTypeConverters::class) + val date: @WriteWith Date +) : Comparable, Parcelable { + + val name: String + get() = "Day $index (${DAY_DATE_FORMAT.format(date)})" + + val shortName: String + get() = DAY_DATE_FORMAT.format(date) + + override fun toString() = name + + override fun compareTo(other: Day): Int { + return index - other.index + } + + companion object { + const val TABLE_NAME = "days" + + private val DAY_DATE_FORMAT = SimpleDateFormat("EEEE", Locale.US).withBelgiumTimeZone() + } +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/model/DetailedEvent.java b/app/src/main/java/be/digitalia/fosdem/model/DetailedEvent.java deleted file mode 100644 index 2712bc6..0000000 --- a/app/src/main/java/be/digitalia/fosdem/model/DetailedEvent.java +++ /dev/null @@ -1,39 +0,0 @@ -package be.digitalia.fosdem.model; - -import android.text.TextUtils; - -import java.util.List; - -import androidx.annotation.NonNull; - -public class DetailedEvent extends Event { - - @NonNull - private List persons; - @NonNull - private List links; - - @Override - @NonNull - public String getPersonsSummary() { - return TextUtils.join(", ", persons); - } - - @NonNull - public List getPersons() { - return persons; - } - - public void setPersons(@NonNull List persons) { - this.persons = persons; - } - - @NonNull - public List getLinks() { - return links; - } - - public void setLinks(@NonNull List links) { - this.links = links; - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/model/DetailedEvent.kt b/app/src/main/java/be/digitalia/fosdem/model/DetailedEvent.kt new file mode 100644 index 0000000..5e29caf --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/model/DetailedEvent.kt @@ -0,0 +1,3 @@ +package be.digitalia.fosdem.model + +data class DetailedEvent(val event: Event, val details: EventDetails) \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/model/DownloadScheduleResult.java b/app/src/main/java/be/digitalia/fosdem/model/DownloadScheduleResult.java deleted file mode 100644 index 89f17ec..0000000 --- a/app/src/main/java/be/digitalia/fosdem/model/DownloadScheduleResult.java +++ /dev/null @@ -1,41 +0,0 @@ -package be.digitalia.fosdem.model; - -public class DownloadScheduleResult { - - private static final DownloadScheduleResult RESULT_ERROR = new DownloadScheduleResult(0); - private static final DownloadScheduleResult RESULT_UP_TO_DATE = new DownloadScheduleResult(0); - - private final int eventsCount; - - private DownloadScheduleResult(int eventsCount) { - this.eventsCount = eventsCount; - } - - public static DownloadScheduleResult success(int eventsCount) { - return new DownloadScheduleResult(eventsCount); - } - - public static DownloadScheduleResult error() { - return RESULT_ERROR; - } - - public static DownloadScheduleResult upToDate() { - return RESULT_UP_TO_DATE; - } - - public boolean isSuccess() { - return this != RESULT_ERROR && this != RESULT_UP_TO_DATE; - } - - public boolean isError() { - return this == RESULT_ERROR; - } - - public boolean isUpToDate() { - return this == RESULT_UP_TO_DATE; - } - - public int getEventsCount() { - return eventsCount; - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/model/DownloadScheduleResult.kt b/app/src/main/java/be/digitalia/fosdem/model/DownloadScheduleResult.kt new file mode 100644 index 0000000..9ffbdc2 --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/model/DownloadScheduleResult.kt @@ -0,0 +1,7 @@ +package be.digitalia.fosdem.model + +sealed class DownloadScheduleResult { + class Success(val eventsCount: Int) : DownloadScheduleResult() + object Error : DownloadScheduleResult() + object UpToDate : DownloadScheduleResult() +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/model/Event.java b/app/src/main/java/be/digitalia/fosdem/model/Event.java deleted file mode 100644 index 344be8a..0000000 --- a/app/src/main/java/be/digitalia/fosdem/model/Event.java +++ /dev/null @@ -1,237 +0,0 @@ -package be.digitalia.fosdem.model; - -import android.os.Parcel; -import android.os.Parcelable; -import androidx.annotation.NonNull; -import androidx.room.ColumnInfo; -import androidx.room.Embedded; -import androidx.room.TypeConverters; -import be.digitalia.fosdem.api.FosdemUrls; -import be.digitalia.fosdem.db.converters.NullableDateTypeConverters; -import be.digitalia.fosdem.utils.DateUtils; - -import java.util.Date; - -public class Event implements Parcelable { - - private long id; - @Embedded(prefix = "day_") - @NonNull - private Day day; - @ColumnInfo(name = "start_time") - @TypeConverters({NullableDateTypeConverters.class}) - private Date startTime; - @ColumnInfo(name = "end_time") - @TypeConverters({NullableDateTypeConverters.class}) - private Date endTime; - @ColumnInfo(name = "room_name") - private String roomName; - private String slug; - private String title; - @ColumnInfo(name = "subtitle") - private String subTitle; - @Embedded(prefix = "track_") - @NonNull - private Track track; - @ColumnInfo(name = "abstract") - private String abstractText; - private String description; - @ColumnInfo(name = "persons") - private String personsSummary; - - public Event() { - } - - public long getId() { - return id; - } - - public void setId(long id) { - this.id = id; - } - - @NonNull - public Day getDay() { - return day; - } - - public void setDay(@NonNull Day day) { - this.day = day; - } - - public Date getStartTime() { - return startTime; - } - - public void setStartTime(Date startTime) { - this.startTime = startTime; - } - - public Date getEndTime() { - return endTime; - } - - public void setEndTime(Date endTime) { - this.endTime = endTime; - } - - public boolean isRunningAtTime(long time) { - return (startTime != null) && (endTime != null) && (startTime.getTime() < time) && (time < endTime.getTime()); - } - - /** - * @return The event duration in minutes - */ - public int getDuration() { - if ((startTime == null) || (endTime == null)) { - return 0; - } - return (int) ((this.endTime.getTime() - this.startTime.getTime()) / android.text.format.DateUtils.MINUTE_IN_MILLIS); - } - - public String getRoomName() { - return (roomName == null) ? "" : roomName; - } - - public void setRoomName(String roomName) { - this.roomName = roomName; - } - - public String getSlug() { - return slug; - } - - public void setSlug(String slug) { - this.slug = slug; - } - - public String getUrl() { - return FosdemUrls.getEvent(slug, DateUtils.getYear(getDay().getDate().getTime())); - } - - public String getTitle() { - return title; - } - - public void setTitle(String title) { - this.title = title; - } - - public String getSubTitle() { - return subTitle; - } - - public void setSubTitle(String subTitle) { - this.subTitle = subTitle; - } - - @NonNull - public Track getTrack() { - return track; - } - - public void setTrack(@NonNull Track track) { - this.track = track; - } - - public String getAbstractText() { - return abstractText; - } - - public void setAbstractText(String abstractText) { - this.abstractText = abstractText; - } - - public String getDescription() { - return description; - } - - public void setDescription(String description) { - this.description = description; - } - - @NonNull - public String getPersonsSummary() { - if (personsSummary != null) { - return personsSummary; - } - return ""; - } - - public void setPersonsSummary(String personsSummary) { - this.personsSummary = personsSummary; - } - - @NonNull - @Override - public String toString() { - return title; - } - - @Override - public int hashCode() { - return (int) (id ^ (id >>> 32)); - } - - @Override - public boolean equals(Object obj) { - if (this == obj) - return true; - if (obj == null) - return false; - Event other = (Event) obj; - return id == other.id; - } - - @Override - public int describeContents() { - return 0; - } - - @Override - public void writeToParcel(Parcel out, int flags) { - out.writeLong(id); - day.writeToParcel(out, flags); - out.writeLong((startTime == null) ? 0L : startTime.getTime()); - out.writeLong((endTime == null) ? 0L : endTime.getTime()); - out.writeString(roomName); - out.writeString(slug); - out.writeString(title); - out.writeString(subTitle); - track.writeToParcel(out, flags); - out.writeString(abstractText); - out.writeString(description); - out.writeString(personsSummary); - } - - public static final Parcelable.Creator CREATOR = new Parcelable.Creator() { - public Event createFromParcel(Parcel in) { - return new Event(in); - } - - public Event[] newArray(int size) { - return new Event[size]; - } - }; - - Event(Parcel in) { - id = in.readLong(); - day = Day.CREATOR.createFromParcel(in); - long time = in.readLong(); - if (time != 0L) { - startTime = new Date(time); - } - time = in.readLong(); - if (time != 0L) { - endTime = new Date(time); - } - roomName = in.readString(); - slug = in.readString(); - title = in.readString(); - subTitle = in.readString(); - track = Track.CREATOR.createFromParcel(in); - abstractText = in.readString(); - description = in.readString(); - personsSummary = in.readString(); - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/model/Event.kt b/app/src/main/java/be/digitalia/fosdem/model/Event.kt new file mode 100644 index 0000000..8ccfd7a --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/model/Event.kt @@ -0,0 +1,58 @@ +package be.digitalia.fosdem.model + +import android.os.Parcelable +import androidx.room.ColumnInfo +import androidx.room.Embedded +import androidx.room.TypeConverters +import be.digitalia.fosdem.api.FosdemUrls +import be.digitalia.fosdem.db.converters.NullableDateTypeConverters +import be.digitalia.fosdem.utils.DateUtils +import kotlinx.android.parcel.Parcelize +import java.util.* + +@Parcelize +data class Event( + val id: Long, + @Embedded(prefix = "day_") + val day: Day, + @ColumnInfo(name = "start_time") + @field:TypeConverters(NullableDateTypeConverters::class) + val startTime: Date? = null, + @ColumnInfo(name = "end_time") + @field:TypeConverters(NullableDateTypeConverters::class) + val endTime: Date? = null, + @ColumnInfo(name = "room_name") + val roomName: String?, + val slug: String?, + val title: String?, + @ColumnInfo(name = "subtitle") + val subTitle: String?, + @Embedded(prefix = "track_") + val track: Track, + @ColumnInfo(name = "abstract") + val abstractText: String?, + val description: String?, + @ColumnInfo(name = "persons") + val personsSummary: String? +) : Parcelable { + + fun isRunningAtTime(time: Long): Boolean { + return startTime != null && endTime != null && time in startTime.time..endTime.time + } + + /** + * @return The event duration in minutes + */ + val duration: Int + get() = if (startTime == null || endTime == null) { + 0 + } else ((endTime.time - startTime.time) / android.text.format.DateUtils.MINUTE_IN_MILLIS).toInt() + + val url: String? + get() { + val s = slug ?: return null + return FosdemUrls.getEvent(s, DateUtils.getYear(day.date.time)) + } + + override fun toString(): String = title ?: "" +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/model/EventDetails.java b/app/src/main/java/be/digitalia/fosdem/model/EventDetails.java deleted file mode 100644 index 35a43c7..0000000 --- a/app/src/main/java/be/digitalia/fosdem/model/EventDetails.java +++ /dev/null @@ -1,28 +0,0 @@ -package be.digitalia.fosdem.model; - -import androidx.annotation.NonNull; - -import java.util.List; - -public class EventDetails { - - @NonNull - private final List persons; - @NonNull - private final List links; - - public EventDetails(@NonNull List persons, @NonNull List links) { - this.persons = persons; - this.links = links; - } - - @NonNull - public List getPersons() { - return persons; - } - - @NonNull - public List getLinks() { - return links; - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/model/EventDetails.kt b/app/src/main/java/be/digitalia/fosdem/model/EventDetails.kt new file mode 100644 index 0000000..1932a3e --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/model/EventDetails.kt @@ -0,0 +1,3 @@ +package be.digitalia.fosdem.model + +data class EventDetails(val persons: List, val links: List) \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/model/Link.java b/app/src/main/java/be/digitalia/fosdem/model/Link.java deleted file mode 100644 index 194cadf..0000000 --- a/app/src/main/java/be/digitalia/fosdem/model/Link.java +++ /dev/null @@ -1,110 +0,0 @@ -package be.digitalia.fosdem.model; - -import android.os.Parcel; -import android.os.Parcelable; -import androidx.annotation.NonNull; -import androidx.room.ColumnInfo; -import androidx.room.Entity; -import androidx.room.Index; -import androidx.room.PrimaryKey; - -@Entity(tableName = "links", indices = {@Index(value = {"event_id"}, name = "link_event_id_idx")}) -public class Link implements Parcelable { - - public static final String TABLE_NAME = "links"; - - @PrimaryKey(autoGenerate = true) - private long id; - @ColumnInfo(name = "event_id") - private long eventId; - @NonNull - private String url; - private String description; - - public Link() { - } - - public long getId() { - return id; - } - - public void setId(long id) { - this.id = id; - } - - public long getEventId() { - return eventId; - } - - public void setEventId(long eventId) { - this.eventId = eventId; - } - - @NonNull - public String getUrl() { - return url; - } - - public void setUrl(@NonNull String url) { - this.url = url; - } - - public String getDescription() { - return description; - } - - public void setDescription(String description) { - this.description = description; - } - - @NonNull - @Override - public String toString() { - return description; - } - - @Override - public int hashCode() { - return url.hashCode(); - } - - @Override - public boolean equals(Object obj) { - if (this == obj) - return true; - if (obj == null) - return false; - Link other = (Link) obj; - return url.equals(other.url); - } - - @Override - public int describeContents() { - return 0; - } - - @Override - public void writeToParcel(Parcel out, int flags) { - out.writeLong(id); - out.writeLong(eventId); - out.writeString(url); - out.writeString(description); - } - - public static final Parcelable.Creator CREATOR = new Parcelable.Creator() { - public Link createFromParcel(Parcel in) { - return new Link(in); - } - - public Link[] newArray(int size) { - return new Link[size]; - } - }; - - Link(Parcel in) { - id = in.readLong(); - eventId = in.readLong(); - url = in.readString(); - description = in.readString(); - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/model/Link.kt b/app/src/main/java/be/digitalia/fosdem/model/Link.kt new file mode 100644 index 0000000..b501e37 --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/model/Link.kt @@ -0,0 +1,26 @@ +package be.digitalia.fosdem.model + +import android.os.Parcelable +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey +import kotlinx.android.parcel.Parcelize + +@Entity(tableName = "links", indices = [Index(value = ["event_id"], name = "link_event_id_idx")]) +@Parcelize +data class Link( + @PrimaryKey(autoGenerate = true) + val id: Long, + @ColumnInfo(name = "event_id") + val eventId: Long, + val url: String, + val description: String? +) : Parcelable { + + constructor(eventId: Long, url: String, description: String?) : this(0L, eventId, url, description) + + companion object { + const val TABLE_NAME = "links" + } +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/model/Person.java b/app/src/main/java/be/digitalia/fosdem/model/Person.java deleted file mode 100644 index 6fcb82c..0000000 --- a/app/src/main/java/be/digitalia/fosdem/model/Person.java +++ /dev/null @@ -1,95 +0,0 @@ -package be.digitalia.fosdem.model; - -import android.os.Parcel; -import android.os.Parcelable; -import androidx.annotation.NonNull; -import androidx.room.ColumnInfo; -import androidx.room.Entity; -import androidx.room.Fts3; -import androidx.room.PrimaryKey; -import be.digitalia.fosdem.api.FosdemUrls; -import be.digitalia.fosdem.utils.StringUtils; - -@Fts3 -@Entity(tableName = Person.TABLE_NAME) -public class Person implements Parcelable { - - public static final String TABLE_NAME = "persons"; - - @PrimaryKey - @ColumnInfo(name = "rowid") - private long id; - private String name; - - public Person() { - } - - public long getId() { - return id; - } - - public void setId(long id) { - this.id = id; - } - - public String getName() { - return name; - } - - public void setName(String name) { - this.name = name; - } - - public String getUrl(int year) { - return FosdemUrls.getPerson(StringUtils.toSlug(name), year); - } - - @NonNull - @Override - public String toString() { - return name; - } - - @Override - public int hashCode() { - return (int) (id ^ (id >>> 32)); - } - - @Override - public boolean equals(Object obj) { - if (this == obj) - return true; - if (obj == null) - return false; - if (getClass() != obj.getClass()) - return false; - Person other = (Person) obj; - return (id == other.id); - } - - @Override - public int describeContents() { - return 0; - } - - @Override - public void writeToParcel(Parcel out, int flags) { - out.writeLong(id); - out.writeString(name); - } - - public static final Parcelable.Creator CREATOR = new Parcelable.Creator() { - public Person createFromParcel(Parcel in) { - return new Person(in); - } - - public Person[] newArray(int size) { - return new Person[size]; - } - }; - - Person(Parcel in) { - id = in.readLong(); - name = in.readString(); - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/model/Person.kt b/app/src/main/java/be/digitalia/fosdem/model/Person.kt new file mode 100644 index 0000000..7caeaf8 --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/model/Person.kt @@ -0,0 +1,32 @@ +package be.digitalia.fosdem.model + +import android.os.Parcelable +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.Fts3 +import androidx.room.PrimaryKey +import be.digitalia.fosdem.api.FosdemUrls +import be.digitalia.fosdem.utils.toSlug +import kotlinx.android.parcel.Parcelize + +@Fts3 +@Entity(tableName = Person.TABLE_NAME) +@Parcelize +data class Person( + @PrimaryKey + @ColumnInfo(name = "rowid") + val id: Long, + val name: String? +) : Parcelable { + + fun getUrl(year: Int): String? { + val n = name ?: return null + return FosdemUrls.getPerson(n.toSlug(), year) + } + + override fun toString(): String = name ?: "" + + companion object { + const val TABLE_NAME = "persons" + } +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/model/RoomStatus.java b/app/src/main/java/be/digitalia/fosdem/model/RoomStatus.java deleted file mode 100644 index 9886f95..0000000 --- a/app/src/main/java/be/digitalia/fosdem/model/RoomStatus.java +++ /dev/null @@ -1,29 +0,0 @@ -package be.digitalia.fosdem.model; - -import androidx.annotation.ColorRes; -import androidx.annotation.StringRes; -import be.digitalia.fosdem.R; - -public enum RoomStatus { - OPEN(R.string.room_status_open, R.color.room_status_open), - FULL(R.string.room_status_full, R.color.room_status_full), - EMERGENCY_EVACUATION(R.string.room_status_emergency_evacuation, R.color.room_status_emergency_evacuation); - - private final int nameResId; - private final int colorResId; - - RoomStatus(@StringRes int nameResId, @ColorRes int colorResId) { - this.nameResId = nameResId; - this.colorResId = colorResId; - } - - @StringRes - public int getNameResId() { - return nameResId; - } - - @ColorRes - public int getColorResId() { - return colorResId; - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/model/RoomStatus.kt b/app/src/main/java/be/digitalia/fosdem/model/RoomStatus.kt new file mode 100644 index 0000000..e4b5e8d --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/model/RoomStatus.kt @@ -0,0 +1,12 @@ +package be.digitalia.fosdem.model + +import androidx.annotation.ColorRes +import androidx.annotation.StringRes +import be.digitalia.fosdem.R + +enum class RoomStatus(@StringRes @get:StringRes val nameResId: Int, + @ColorRes @get:ColorRes val colorResId: Int) { + OPEN(R.string.room_status_open, R.color.room_status_open), + FULL(R.string.room_status_full, R.color.room_status_full), + EMERGENCY_EVACUATION(R.string.room_status_emergency_evacuation, R.color.room_status_emergency_evacuation) +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/model/StatusEvent.java b/app/src/main/java/be/digitalia/fosdem/model/StatusEvent.java deleted file mode 100644 index 7af56d7..0000000 --- a/app/src/main/java/be/digitalia/fosdem/model/StatusEvent.java +++ /dev/null @@ -1,39 +0,0 @@ -package be.digitalia.fosdem.model; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.room.ColumnInfo; -import androidx.room.Embedded; - -public class StatusEvent { - - @Embedded - @NonNull - private Event event; - @ColumnInfo(name = "is_bookmarked") - private boolean isBookmarked; - - public StatusEvent(@NonNull Event event, boolean isBookmarked) { - this.event = event; - this.isBookmarked = isBookmarked; - } - - @NonNull - public Event getEvent() { - return event; - } - - public boolean isBookmarked() { - return isBookmarked; - } - - @Override - public boolean equals(@Nullable Object obj) { - if (this == obj) - return true; - if (obj == null) - return false; - StatusEvent other = (StatusEvent) obj; - return event.equals(other.event); - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/model/StatusEvent.kt b/app/src/main/java/be/digitalia/fosdem/model/StatusEvent.kt new file mode 100644 index 0000000..97a5623 --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/model/StatusEvent.kt @@ -0,0 +1,11 @@ +package be.digitalia.fosdem.model + +import androidx.room.ColumnInfo +import androidx.room.Embedded + +data class StatusEvent( + @Embedded + val event: Event, + @ColumnInfo(name = "is_bookmarked") + val isBookmarked: Boolean +) \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/model/Track.java b/app/src/main/java/be/digitalia/fosdem/model/Track.java deleted file mode 100644 index b5353ec..0000000 --- a/app/src/main/java/be/digitalia/fosdem/model/Track.java +++ /dev/null @@ -1,150 +0,0 @@ -package be.digitalia.fosdem.model; - -import android.os.Parcel; -import android.os.Parcelable; - -import androidx.annotation.ColorRes; -import androidx.annotation.NonNull; -import androidx.annotation.StringRes; -import androidx.room.Entity; -import androidx.room.Index; -import androidx.room.PrimaryKey; -import be.digitalia.fosdem.R; - -@Entity(tableName = Track.TABLE_NAME, indices = {@Index(value = {"name", "type"}, name = "track_main_idx", unique = true)}) -public class Track implements Parcelable { - - public static final String TABLE_NAME = "tracks"; - - public enum Type { - other(R.string.other, - R.color.track_type_other, R.color.track_type_other_dark, R.color.track_type_other_text), - keynote(R.string.keynote, - R.color.track_type_keynote, R.color.track_type_keynote_dark, R.color.track_type_keynote_text), - maintrack(R.string.main_track, - R.color.track_type_main, R.color.track_type_main_dark, R.color.track_type_main_text), - devroom(R.string.developer_room, - R.color.track_type_developer_room, R.color.track_type_developer_room_dark, R.color.track_type_developer_room_text), - lightningtalk(R.string.lightning_talk, - R.color.track_type_lightning_talk, R.color.track_type_lightning_talk_dark, R.color.track_type_lightning_talk_text), - certification(R.string.certification_exam, - R.color.track_type_certification_exam, R.color.track_type_certification_exam_dark, R.color.track_type_certification_exam_text); - - private final int nameResId; - private final int appBarColorResId; - private final int statusBarColorResId; - private final int textColorResId; - - Type(@StringRes int nameResId, - @ColorRes int appBarColorResId, - @ColorRes int statusBarColorResId, - @ColorRes int textColorResId) { - this.nameResId = nameResId; - this.appBarColorResId = appBarColorResId; - this.statusBarColorResId = statusBarColorResId; - this.textColorResId = textColorResId; - } - - @StringRes - public int getNameResId() { - return nameResId; - } - - @ColorRes - public int getAppBarColorResId() { - return appBarColorResId; - } - - @ColorRes - public int getStatusBarColorResId() { - return statusBarColorResId; - } - - public int getTextColorResId() { - return textColorResId; - } - } - - @PrimaryKey - private long id; - @NonNull - private String name; - @NonNull - private Type type; - - public Track(@NonNull String name, @NonNull Type type) { - this.name = name; - this.type = type; - } - - public long getId() { - return id; - } - - public void setId(long id) { - this.id = id; - } - - @NonNull - public String getName() { - return name; - } - - @NonNull - public Type getType() { - return type; - } - - @NonNull - @Override - public String toString() { - return name; - } - - @Override - public int hashCode() { - final int prime = 31; - int result = 1; - result = prime * result + name.hashCode(); - result = prime * result + type.hashCode(); - return result; - } - - @Override - public boolean equals(Object obj) { - if (this == obj) - return true; - if (obj == null) - return false; - Track other = (Track) obj; - return name.equals(other.name) && (type == other.type); - } - - @Override - public int describeContents() { - return 0; - } - - @Override - public void writeToParcel(Parcel out, int flags) { - out.writeLong(id); - out.writeString(name); - out.writeInt(type.ordinal()); - } - - public static final Parcelable.Creator CREATOR = new Parcelable.Creator() { - public Track createFromParcel(Parcel in) { - return new Track(in); - } - - public Track[] newArray(int size) { - return new Track[size]; - } - }; - - Track(Parcel in) { - id = in.readLong(); - name = in.readString(); - type = Type.values()[in.readInt()]; - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/model/Track.kt b/app/src/main/java/be/digitalia/fosdem/model/Track.kt new file mode 100644 index 0000000..cd3e5fb --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/model/Track.kt @@ -0,0 +1,62 @@ +package be.digitalia.fosdem.model + +import android.os.Parcelable +import androidx.annotation.ColorRes +import androidx.annotation.StringRes +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey +import be.digitalia.fosdem.R +import kotlinx.android.parcel.Parcelize + +@Entity(tableName = Track.TABLE_NAME, indices = [Index(value = ["name", "type"], name = "track_main_idx", unique = true)]) +@Parcelize +data class Track( + @PrimaryKey + val id: Long, + val name: String, + val type: Type +) : Parcelable { + + constructor(name: String, type: Type) : this(0L, name, type) + + enum class Type(@StringRes @get:StringRes val nameResId: Int, + @ColorRes @get:ColorRes val appBarColorResId: Int, + @ColorRes @get:ColorRes val statusBarColorResId: Int, + @ColorRes @get:ColorRes val textColorResId: Int) { + + other(R.string.other, + R.color.track_type_other, R.color.track_type_other_dark, R.color.track_type_other_text), + keynote(R.string.keynote, + R.color.track_type_keynote, R.color.track_type_keynote_dark, R.color.track_type_keynote_text), + maintrack(R.string.main_track, + R.color.track_type_main, R.color.track_type_main_dark, R.color.track_type_main_text), + devroom(R.string.developer_room, + R.color.track_type_developer_room, R.color.track_type_developer_room_dark, R.color.track_type_developer_room_text), + lightningtalk(R.string.lightning_talk, + R.color.track_type_lightning_talk, R.color.track_type_lightning_talk_dark, R.color.track_type_lightning_talk_text), + certification(R.string.certification_exam, + R.color.track_type_certification_exam, R.color.track_type_certification_exam_dark, R.color.track_type_certification_exam_text); + + } + + override fun toString() = name + + override fun hashCode(): Int { + val prime = 31 + var result = 1 + result = prime * result + name.hashCode() + result = prime * result + type.hashCode() + return result + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Track) return false + return name == other.name && type == other.type + } + + companion object { + const val TABLE_NAME = "tracks" + } +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/parsers/AbstractJsonPullParser.java b/app/src/main/java/be/digitalia/fosdem/parsers/AbstractJsonPullParser.java deleted file mode 100644 index cdb90dd..0000000 --- a/app/src/main/java/be/digitalia/fosdem/parsers/AbstractJsonPullParser.java +++ /dev/null @@ -1,18 +0,0 @@ -package be.digitalia.fosdem.parsers; - -import android.util.JsonReader; - -import java.io.InputStreamReader; - -import okio.BufferedSource; - -public abstract class AbstractJsonPullParser implements Parser { - - @Override - public T parse(BufferedSource source) throws Exception { - JsonReader reader = new JsonReader(new InputStreamReader(source.inputStream())); - return parse(reader); - } - - protected abstract T parse(JsonReader reader) throws Exception; -} diff --git a/app/src/main/java/be/digitalia/fosdem/parsers/AbstractPullParser.java b/app/src/main/java/be/digitalia/fosdem/parsers/AbstractPullParser.java deleted file mode 100644 index 415f12b..0000000 --- a/app/src/main/java/be/digitalia/fosdem/parsers/AbstractPullParser.java +++ /dev/null @@ -1,77 +0,0 @@ -package be.digitalia.fosdem.parsers; - -import org.xmlpull.v1.XmlPullParser; -import org.xmlpull.v1.XmlPullParserException; -import org.xmlpull.v1.XmlPullParserFactory; - -import java.io.IOException; - -import okio.BufferedSource; - -/** - * Base class with helper methods for XML pull parsing. - * - * @author Christophe Beyls - */ -public abstract class AbstractPullParser implements Parser { - private static XmlPullParserFactory factory; - - private static XmlPullParserFactory getFactory() throws XmlPullParserException { - if (factory == null) { - factory = XmlPullParserFactory.newInstance(); - } - - return factory; - } - - private XmlPullParser m_parser; - - /* - * Checks if the current event is the end of the document - */ - protected boolean isEndDocument() throws XmlPullParserException { - return (m_parser.getEventType() == XmlPullParser.END_DOCUMENT); - } - - /* - * Checks if the current event is a start tag - */ - protected boolean isStartTag() throws XmlPullParserException { - return (m_parser.getEventType() == XmlPullParser.START_TAG); - } - - /* - * Checks if the current event is a start tag with the specified local name - */ - protected boolean isStartTag(String name) throws XmlPullParserException { - return (m_parser.getEventType() == XmlPullParser.START_TAG) && name.equals(m_parser.getName()); - } - - /* - * Go to the next event and check if the current event is an end tag with the specified local name - */ - protected boolean isNextEndTag(String name) throws XmlPullParserException, IOException { - return (m_parser.next() == XmlPullParser.END_TAG) && name.equals(m_parser.getName()); - } - - /* - * Skips the start tag and positions the reader on the corresponding end tag - */ - protected void skipToEndTag() throws XmlPullParserException, IOException { - int type; - while ((type = m_parser.next()) != XmlPullParser.END_TAG) { - if (type == XmlPullParser.START_TAG) - skipToEndTag(); - } - } - - @Override - public T parse(BufferedSource source) throws Exception { - m_parser = getFactory().newPullParser(); - m_parser.setInput(source.inputStream(), null); - - return parse(m_parser); - } - - protected abstract T parse(XmlPullParser parser) throws Exception; -} diff --git a/app/src/main/java/be/digitalia/fosdem/parsers/EventsParser.java b/app/src/main/java/be/digitalia/fosdem/parsers/EventsParser.java deleted file mode 100644 index 9132ac1..0000000 --- a/app/src/main/java/be/digitalia/fosdem/parsers/EventsParser.java +++ /dev/null @@ -1,189 +0,0 @@ -package be.digitalia.fosdem.parsers; - -import android.text.TextUtils; - -import org.xmlpull.v1.XmlPullParser; - -import java.text.DateFormat; -import java.text.SimpleDateFormat; -import java.util.ArrayList; -import java.util.Calendar; -import java.util.List; -import java.util.Locale; - -import be.digitalia.fosdem.model.Day; -import be.digitalia.fosdem.model.DetailedEvent; -import be.digitalia.fosdem.model.Link; -import be.digitalia.fosdem.model.Person; -import be.digitalia.fosdem.model.Track; -import be.digitalia.fosdem.utils.DateUtils; - -/** - * Main parser for FOSDEM schedule data in pentabarf XML format. - * - * @author Christophe Beyls - */ -public class EventsParser extends IterableAbstractPullParser { - - private final DateFormat DATE_FORMAT = DateUtils.withBelgiumTimeZone(new SimpleDateFormat("yyyy-MM-dd", Locale.US)); - - // Calendar used to compute the events time, according to Belgium timezone - private final Calendar calendar = Calendar.getInstance(DateUtils.getBelgiumTimeZone(), Locale.US); - - private Day currentDay; - private String currentRoom; - private Track currentTrack; - - /** - * Returns the hours portion of a time string in the "hh:mm" format, without allocating objects. - * - * @param time string in the "hh:mm" format - * @return hours - */ - private static int getHours(String time) { - return (Character.getNumericValue(time.charAt(0)) * 10) + Character.getNumericValue(time.charAt(1)); - } - - /** - * Returns the minutes portion of a time string in the "hh:mm" format, without allocating objects. - * - * @param time string in the "hh:mm" format - * @return minutes - */ - private static int getMinutes(String time) { - return (Character.getNumericValue(time.charAt(3)) * 10) + Character.getNumericValue(time.charAt(4)); - } - - @Override - protected boolean parseHeader(XmlPullParser parser) throws Exception { - while (!isEndDocument()) { - if (isStartTag("schedule")) { - return true; - } - - parser.next(); - } - return false; - } - - @Override - protected DetailedEvent parseNext(XmlPullParser parser) throws Exception { - while (!isNextEndTag("schedule")) { - if (isStartTag()) { - - switch (parser.getName()) { - case "day": - currentDay = new Day(); - currentDay.setIndex(Integer.parseInt(parser.getAttributeValue(null, "index"))); - currentDay.setDate(DATE_FORMAT.parse(parser.getAttributeValue(null, "date"))); - break; - case "room": - currentRoom = parser.getAttributeValue(null, "name"); - break; - case "event": - DetailedEvent event = new DetailedEvent(); - event.setId(Long.parseLong(parser.getAttributeValue(null, "id"))); - event.setDay(currentDay); - event.setRoomName(currentRoom); - // Initialize empty lists - List persons = new ArrayList<>(); - event.setPersons(persons); - List links = new ArrayList<>(); - event.setLinks(links); - - String duration = null; - String trackName = ""; - Track.Type trackType = Track.Type.other; - - while (!isNextEndTag("event")) { - if (isStartTag()) { - - switch (parser.getName()) { - case "start": - String time = parser.nextText(); - if (!TextUtils.isEmpty(time)) { - calendar.setTime(currentDay.getDate()); - calendar.set(Calendar.HOUR_OF_DAY, getHours(time)); - calendar.set(Calendar.MINUTE, getMinutes(time)); - event.setStartTime(calendar.getTime()); - } - break; - case "duration": - duration = parser.nextText(); - break; - case "slug": - event.setSlug(parser.nextText()); - break; - case "title": - event.setTitle(parser.nextText()); - break; - case "subtitle": - event.setSubTitle(parser.nextText()); - break; - case "track": - trackName = parser.nextText(); - break; - case "type": - try { - trackType = Enum.valueOf(Track.Type.class, parser.nextText()); - } catch (Exception e) { - // trackType will be "other" - } - break; - case "abstract": - event.setAbstractText(parser.nextText()); - break; - case "description": - event.setDescription(parser.nextText()); - break; - case "persons": - while (!isNextEndTag("persons")) { - if (isStartTag("person")) { - Person person = new Person(); - person.setId(Long.parseLong(parser.getAttributeValue(null, "id"))); - person.setName(parser.nextText()); - - persons.add(person); - } - } - break; - case "links": - while (!isNextEndTag("links")) { - if (isStartTag("link")) { - Link link = new Link(); - link.setEventId(event.getId()); - link.setUrl(parser.getAttributeValue(null, "href")); - link.setDescription(parser.nextText()); - - links.add(link); - } - } - break; - default: - skipToEndTag(); - break; - } - } - } - - if ((event.getStartTime() != null) && !TextUtils.isEmpty(duration)) { - calendar.add(Calendar.HOUR_OF_DAY, getHours(duration)); - calendar.add(Calendar.MINUTE, getMinutes(duration)); - event.setEndTime(calendar.getTime()); - } - - if ((currentTrack == null) || !trackName.equals(currentTrack.getName()) || (trackType != currentTrack.getType())) { - currentTrack = new Track(trackName, trackType); - } - event.setTrack(currentTrack); - - return event; - default: - skipToEndTag(); - break; - } - } - } - return null; - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/parsers/EventsParser.kt b/app/src/main/java/be/digitalia/fosdem/parsers/EventsParser.kt new file mode 100644 index 0000000..59d0c90 --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/parsers/EventsParser.kt @@ -0,0 +1,166 @@ +package be.digitalia.fosdem.parsers + +import be.digitalia.fosdem.model.* +import be.digitalia.fosdem.utils.* +import be.digitalia.fosdem.utils.DateUtils.belgiumTimeZone +import be.digitalia.fosdem.utils.DateUtils.withBelgiumTimeZone +import okio.BufferedSource +import org.xmlpull.v1.XmlPullParser +import java.text.SimpleDateFormat +import java.util.* + +/** + * Main parser for FOSDEM schedule data in pentabarf XML format. + * + * @author Christophe Beyls + */ +class EventsParser : Parser> { + + private val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.US).withBelgiumTimeZone() + // Calendar used to compute the events time, according to Belgium timezone + private val calendar = Calendar.getInstance(belgiumTimeZone, Locale.US) + + override fun parse(source: BufferedSource): Sequence { + val parser: XmlPullParser = xmlPullParserFactory.newPullParser().apply { + setInput(source.inputStream(), null) + } + return sequence { + while (!parser.isEndDocument) { + if (parser.isStartTag("schedule")) { + var currentDay: Day? = null + var currentRoomName: String? = null + + while (!parser.isNextEndTag("schedule")) { + if (parser.isStartTag) { + when (parser.name) { + "day" -> { + currentDay = Day( + index = parser.getAttributeValue(null, "index")!!.toInt(), + date = dateFormat.parse(parser.getAttributeValue(null, "date"))!! + ) + } + "room" -> currentRoomName = parser.getAttributeValue(null, "name") + "event" -> yield(parseEvent(parser, currentDay!!, currentRoomName)) + else -> parser.skipToEndTag() + } + } + } + } + parser.next() + } + } + } + + private fun parseEvent(parser: XmlPullParser, day: Day, roomName: String?): DetailedEvent { + val id = parser.getAttributeValue(null, "id")!!.toLong() + var startTime: Date? = null + var duration: String? = null + var slug: String? = null + var title: String? = null + var subTitle: String? = null + var trackName = "" + var trackType = Track.Type.other + var abstractText: String? = null + var description: String? = null + val persons = mutableListOf() + val links = mutableListOf() + + while (!parser.isNextEndTag("event")) { + if (parser.isStartTag) { + when (parser.name) { + "start" -> { + val timeString = parser.nextText() + if (!timeString.isNullOrEmpty()) { + startTime = with(calendar) { + time = day.date + set(Calendar.HOUR_OF_DAY, getHours(timeString)) + set(Calendar.MINUTE, getMinutes(timeString)) + time + } + } + } + "duration" -> duration = parser.nextText() + "slug" -> slug = parser.nextText() + "title" -> title = parser.nextText() + "subtitle" -> subTitle = parser.nextText() + "track" -> trackName = parser.nextText() + "type" -> try { + trackType = enumValueOf(parser.nextText()) + } catch (e: Exception) { + // trackType will be "other" + } + "abstract" -> abstractText = parser.nextText() + "description" -> description = parser.nextText() + "persons" -> while (!parser.isNextEndTag("persons")) { + if (parser.isStartTag("person")) { + val person = Person( + id = parser.getAttributeValue(null, "id")!!.toLong(), + name = parser.nextText()!! + ) + persons.add(person) + } + } + "links" -> while (!parser.isNextEndTag("links")) { + if (parser.isStartTag("link")) { + val link = Link( + eventId = id, + url = parser.getAttributeValue(null, "href")!!, + description = parser.nextText() + ) + links.add(link) + } + } + else -> parser.skipToEndTag() + } + } + } + + val endTime = if (startTime != null && !duration.isNullOrEmpty()) { + with(calendar) { + add(Calendar.HOUR_OF_DAY, getHours(duration)) + add(Calendar.MINUTE, getMinutes(duration)) + time + } + } else null + + val event = Event( + id = id, + day = day, + roomName = roomName, + startTime = startTime, + endTime = endTime, + slug = slug, + title = title, + subTitle = subTitle, + track = Track(trackName, trackType), + abstractText = abstractText, + description = description, + personsSummary = null + ) + val details = EventDetails( + persons = persons, + links = links + ) + return DetailedEvent(event, details) + } + + /** + * Returns the hours portion of a time string in the "hh:mm" format, without allocating objects. + * + * @param time string in the "hh:mm" format + * @return hours + */ + private fun getHours(time: String): Int { + return Character.getNumericValue(time[0]) * 10 + Character.getNumericValue(time[1]) + } + + /** + * Returns the minutes portion of a time string in the "hh:mm" format, without allocating objects. + * + * @param time string in the "hh:mm" format + * @return minutes + */ + private fun getMinutes(time: String): Int { + return Character.getNumericValue(time[3]) * 10 + Character.getNumericValue(time[4]) + } +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/parsers/IterableAbstractPullParser.java b/app/src/main/java/be/digitalia/fosdem/parsers/IterableAbstractPullParser.java deleted file mode 100644 index 23fc74e..0000000 --- a/app/src/main/java/be/digitalia/fosdem/parsers/IterableAbstractPullParser.java +++ /dev/null @@ -1,80 +0,0 @@ -package be.digitalia.fosdem.parsers; - -import org.xmlpull.v1.XmlPullParser; - -import java.util.Iterator; -import java.util.NoSuchElementException; - -/** - * An abstract class for easy implementation of an iterable pull parser. - * - * @author Christophe Beyls - */ -public abstract class IterableAbstractPullParser extends AbstractPullParser> { - - private class ParserIterator implements Iterator { - - private final XmlPullParser parser; - private T next = null; - - public ParserIterator(XmlPullParser parser) { - this.parser = parser; - try { - if (parseHeader(parser)) { - next = parseNext(parser); - } - } catch (Exception e) { - throw new RuntimeException(e); - } - } - - @Override - public boolean hasNext() { - return next != null; - } - - @Override - public T next() { - if (next == null) { - throw new NoSuchElementException(); - } - T current = next; - try { - next = parseNext(parser); - if (next == null) { - parseFooter(parser); - } - } catch (Exception e) { - throw new RuntimeException(e); - } - return current; - } - - @Override - public void remove() { - throw new UnsupportedOperationException(); - } - - } - - @Override - protected Iterable parse(final XmlPullParser parser) throws Exception { - return () -> new ParserIterator(parser); - } - - /** - * @return true if the header was parsed successfully and the main items list has been reached. - */ - protected abstract boolean parseHeader(XmlPullParser parser) throws Exception; - - /** - * @return the next item, or null if no more items are found. - */ - protected abstract T parseNext(XmlPullParser parser) throws Exception; - - protected void parseFooter(XmlPullParser parser) throws Exception { - while (!isEndDocument()) { - parser.next(); - } - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/parsers/Parser.java b/app/src/main/java/be/digitalia/fosdem/parsers/Parser.java deleted file mode 100644 index ad4e84d..0000000 --- a/app/src/main/java/be/digitalia/fosdem/parsers/Parser.java +++ /dev/null @@ -1,7 +0,0 @@ -package be.digitalia.fosdem.parsers; - -import okio.BufferedSource; - -public interface Parser { - T parse(BufferedSource source) throws Exception; -} diff --git a/app/src/main/java/be/digitalia/fosdem/parsers/Parser.kt b/app/src/main/java/be/digitalia/fosdem/parsers/Parser.kt new file mode 100644 index 0000000..1bd09d2 --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/parsers/Parser.kt @@ -0,0 +1,8 @@ +package be.digitalia.fosdem.parsers + +import okio.BufferedSource + +interface Parser { + @Throws(Exception::class) + fun parse(source: BufferedSource): T +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/parsers/RoomStatusesParser.java b/app/src/main/java/be/digitalia/fosdem/parsers/RoomStatusesParser.java deleted file mode 100644 index a7f5c7a..0000000 --- a/app/src/main/java/be/digitalia/fosdem/parsers/RoomStatusesParser.java +++ /dev/null @@ -1,49 +0,0 @@ -package be.digitalia.fosdem.parsers; - -import android.util.JsonReader; - -import java.util.HashMap; -import java.util.Map; - -import be.digitalia.fosdem.model.RoomStatus; - -public class RoomStatusesParser extends AbstractJsonPullParser> { - - @Override - protected Map parse(JsonReader reader) throws Exception { - Map result = new HashMap<>(); - - reader.beginArray(); - while (reader.hasNext()) { - String roomName = null; - RoomStatus roomStatus = null; - - reader.beginObject(); - while (reader.hasNext()) { - switch (reader.nextName()) { - case "roomname": - roomName = reader.nextString(); - break; - case "state": - String stateValue = reader.nextString(); - try { - roomStatus = RoomStatus.values()[Integer.parseInt(stateValue)]; - } catch (Exception e) { - // Swallow and ignore that room - } - break; - default: - reader.skipValue(); - } - } - reader.endObject(); - - if (roomName != null && roomStatus != null) { - result.put(roomName, roomStatus); - } - } - reader.endArray(); - - return result; - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/parsers/RoomStatusesParser.kt b/app/src/main/java/be/digitalia/fosdem/parsers/RoomStatusesParser.kt new file mode 100644 index 0000000..3b539aa --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/parsers/RoomStatusesParser.kt @@ -0,0 +1,44 @@ +package be.digitalia.fosdem.parsers + +import android.util.JsonReader +import be.digitalia.fosdem.model.RoomStatus +import okio.BufferedSource +import java.io.InputStreamReader + +class RoomStatusesParser : Parser> { + + override fun parse(source: BufferedSource): Map { + val reader = JsonReader(InputStreamReader(source.inputStream())) + val result = mutableMapOf() + + reader.beginArray() + while (reader.hasNext()) { + var roomName: String? = null + var roomStatus: RoomStatus? = null + + reader.beginObject() + while (reader.hasNext()) { + when (reader.nextName()) { + "roomname" -> roomName = reader.nextString() + "state" -> { + val stateValue = reader.nextString() + try { + roomStatus = RoomStatus.values()[stateValue.toInt()] + } catch (e: Exception) { + // Swallow and ignore that room + } + } + else -> reader.skipValue() + } + } + reader.endObject() + + if (roomName != null && roomStatus != null) { + result[roomName] = roomStatus + } + } + reader.endArray() + + return result + } +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/providers/BookmarksExportProvider.java b/app/src/main/java/be/digitalia/fosdem/providers/BookmarksExportProvider.java deleted file mode 100644 index 151c59b..0000000 --- a/app/src/main/java/be/digitalia/fosdem/providers/BookmarksExportProvider.java +++ /dev/null @@ -1,215 +0,0 @@ -package be.digitalia.fosdem.providers; - -import android.app.Activity; -import android.content.ContentProvider; -import android.content.ContentValues; -import android.content.Intent; -import android.database.Cursor; -import android.database.MatrixCursor; -import android.net.Uri; -import android.os.ParcelFileDescriptor; -import android.provider.OpenableColumns; -import android.text.TextUtils; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.app.ShareCompat; - -import java.io.FileNotFoundException; -import java.io.IOException; -import java.io.OutputStream; -import java.text.DateFormat; -import java.text.SimpleDateFormat; -import java.util.Calendar; -import java.util.Locale; -import java.util.TimeZone; - -import be.digitalia.fosdem.BuildConfig; -import be.digitalia.fosdem.R; -import be.digitalia.fosdem.api.FosdemUrls; -import be.digitalia.fosdem.db.AppDatabase; -import be.digitalia.fosdem.model.Event; -import be.digitalia.fosdem.utils.DateUtils; -import be.digitalia.fosdem.utils.ICalendarWriter; -import be.digitalia.fosdem.utils.StringUtils; -import okio.Okio; - -/** - * Content Provider generating the current bookmarks list in iCalendar format. - */ -public class BookmarksExportProvider extends ContentProvider { - - private static final Uri URI = new Uri.Builder() - .scheme("content") - .authority(BuildConfig.APPLICATION_ID + ".bookmarks") - .appendPath("bookmarks.ics") - .build(); - private static final String TYPE = "text/calendar"; - - private static final String[] COLUMNS = {OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE}; - - - public static Intent getIntent(Activity activity) { - // Supports granting read permission for the attached shared file - return ShareCompat.IntentBuilder.from(activity) - .setStream(URI) - .setType(TYPE) - .getIntent() - .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); - } - - @Override - public boolean onCreate() { - return true; - } - - @Nullable - @Override - public Uri insert(@NonNull Uri uri, ContentValues values) { - throw new UnsupportedOperationException(); - } - - @Override - public int update(@NonNull Uri uri, ContentValues values, String selection, String[] selectionArgs) { - throw new UnsupportedOperationException(); - } - - @Override - public int delete(@NonNull Uri uri, String selection, String[] selectionArgs) { - throw new UnsupportedOperationException(); - } - - @Nullable - @Override - public String getType(@NonNull Uri uri) { - return TYPE; - } - - @Nullable - @Override - public Cursor query(@NonNull Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) { - if (projection == null) { - projection = COLUMNS; - } - String[] cols = new String[projection.length]; - Object[] values = new Object[projection.length]; - int i = 0; - for (String col : projection) { - if (OpenableColumns.DISPLAY_NAME.equals(col)) { - cols[i] = OpenableColumns.DISPLAY_NAME; - values[i++] = getContext().getString(R.string.export_bookmarks_file_name, AppDatabase.getInstance(getContext()).getScheduleDao().getYear()); - } else if (OpenableColumns.SIZE.equals(col)) { - cols[i] = OpenableColumns.SIZE; - // Unknown size, content will be generated on-the-fly - values[i++] = 1024L; - } - } - - cols = copyOf(cols, i); - values = copyOf(values, i); - - final MatrixCursor cursor = new MatrixCursor(cols, 1); - cursor.addRow(values); - return cursor; - } - - @Nullable - @Override - public ParcelFileDescriptor openFile(@NonNull Uri uri, @NonNull String mode) throws FileNotFoundException { - try { - ParcelFileDescriptor[] pipe = ParcelFileDescriptor.createPipe(); - new DownloadThread( - new ParcelFileDescriptor.AutoCloseOutputStream(pipe[1]), - AppDatabase.getInstance(getContext()) - ).start(); - return pipe[0]; - } catch (IOException e) { - throw new FileNotFoundException("Could not open pipe"); - } - } - - private static String[] copyOf(String[] original, int newLength) { - final String[] result = new String[newLength]; - System.arraycopy(original, 0, result, 0, newLength); - return result; - } - - private static Object[] copyOf(Object[] original, int newLength) { - final Object[] result = new Object[newLength]; - System.arraycopy(original, 0, result, 0, newLength); - return result; - } - - - static class DownloadThread extends Thread { - - private final OutputStream outputStream; - private final AppDatabase appDatabase; - private final Calendar calendar = Calendar.getInstance(DateUtils.getBelgiumTimeZone(), Locale.US); - private final DateFormat dateFormat; - private final String dtStamp; - private final TextUtils.StringSplitter personsSplitter = new StringUtils.SimpleStringSplitter(", "); - - DownloadThread(OutputStream outputStream, AppDatabase appDatabase) { - this.outputStream = outputStream; - this.appDatabase = appDatabase; - // Format all times in GMT - this.dateFormat = new SimpleDateFormat("yyyyMMdd'T'HHmmss'Z'", Locale.US); - this.dateFormat.setTimeZone(TimeZone.getTimeZone("GMT+0")); - this.dtStamp = dateFormat.format(System.currentTimeMillis()); - } - - @Override - public void run() { - try (ICalendarWriter writer = new ICalendarWriter(Okio.buffer(Okio.sink(outputStream)))) { - final Event[] bookmarks = appDatabase.getBookmarksDao().getBookmarks(); - writer.write("BEGIN", "VCALENDAR"); - writer.write("VERSION", "2.0"); - writer.write("PRODID", "-//" + BuildConfig.APPLICATION_ID + "//NONSGML " + BuildConfig.VERSION_NAME + "//EN"); - - for (Event event : bookmarks) { - writeEvent(writer, event); - } - - writer.write("END", "VCALENDAR"); - } catch (Exception ignore) { - } - } - - private void writeEvent(ICalendarWriter writer, Event event) throws IOException { - writer.write("BEGIN", "VEVENT"); - - final int year = DateUtils.getYear(event.getDay().getDate().getTime(), calendar); - writer.write("UID", String.format(Locale.US, "%1$d@%2$d@%3$s", event.getId(), year, BuildConfig.APPLICATION_ID)); - writer.write("DTSTAMP", dtStamp); - if (event.getStartTime() != null) { - writer.write("DTSTART", dateFormat.format(event.getStartTime())); - } - if (event.getEndTime() != null) { - writer.write("DTEND", dateFormat.format(event.getEndTime())); - } - writer.write("SUMMARY", event.getTitle()); - String description = event.getAbstractText(); - if (TextUtils.isEmpty(description)) { - description = event.getDescription(); - } - if (!TextUtils.isEmpty(description)) { - writer.write("DESCRIPTION", StringUtils.stripHtml(description)); - writer.write("X-ALT-DESC", description); - } - writer.write("CLASS", "PUBLIC"); - writer.write("CATEGORIES", event.getTrack().getName()); - writer.write("URL", event.getUrl()); - writer.write("LOCATION", event.getRoomName()); - - personsSplitter.setString(event.getPersonsSummary()); - for (String name : personsSplitter) { - String key = String.format(Locale.US, "ATTENDEE;ROLE=REQ-PARTICIPANT;CUTYPE=INDIVIDUAL;CN=\"%1$s\"", name); - String url = FosdemUrls.getPerson(StringUtils.toSlug(name), year); - writer.write(key, url); - } - - writer.write("END", "VEVENT"); - } - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/providers/BookmarksExportProvider.kt b/app/src/main/java/be/digitalia/fosdem/providers/BookmarksExportProvider.kt new file mode 100644 index 0000000..b7f1630 --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/providers/BookmarksExportProvider.kt @@ -0,0 +1,162 @@ +package be.digitalia.fosdem.providers + +import android.app.Activity +import android.content.ContentProvider +import android.content.ContentValues +import android.content.Intent +import android.database.Cursor +import android.database.MatrixCursor +import android.net.Uri +import android.os.ParcelFileDescriptor +import android.provider.OpenableColumns +import androidx.core.app.ShareCompat +import be.digitalia.fosdem.BuildConfig +import be.digitalia.fosdem.R +import be.digitalia.fosdem.api.FosdemUrls +import be.digitalia.fosdem.db.AppDatabase +import be.digitalia.fosdem.model.Event +import be.digitalia.fosdem.utils.DateUtils +import be.digitalia.fosdem.utils.ICalendarWriter +import be.digitalia.fosdem.utils.stripHtml +import be.digitalia.fosdem.utils.toSlug +import okio.buffer +import okio.sink +import java.io.FileNotFoundException +import java.io.IOException +import java.io.OutputStream +import java.text.SimpleDateFormat +import java.util.* + +/** + * Content Provider generating the current bookmarks list in iCalendar format. + */ +class BookmarksExportProvider : ContentProvider() { + + override fun onCreate() = true + + override fun insert(uri: Uri, values: ContentValues?) = throw UnsupportedOperationException() + + override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array?) = throw UnsupportedOperationException() + + override fun delete(uri: Uri, selection: String?, selectionArgs: Array?) = throw UnsupportedOperationException() + + override fun getType(uri: Uri) = TYPE + + override fun query(uri: Uri, projection: Array?, selection: String?, selectionArgs: Array?, sortOrder: String?): Cursor? { + val ctx = context!! + val proj = projection ?: COLUMNS + val cols = arrayOfNulls(proj.size) + val values = arrayOfNulls(proj.size) + var columnCount = 0 + for (col in proj) { + when (col) { + OpenableColumns.DISPLAY_NAME -> { + cols[columnCount] = OpenableColumns.DISPLAY_NAME + values[columnCount++] = ctx.getString(R.string.export_bookmarks_file_name, AppDatabase.getInstance(ctx).scheduleDao.getYear()) + } + OpenableColumns.SIZE -> { + cols[columnCount] = OpenableColumns.SIZE + // Unknown size, content will be generated on-the-fly + values[columnCount++] = 1024L + } + } + } + + val cursor = MatrixCursor(cols.copyOfRange(0, columnCount), 1) + cursor.addRow(values.copyOfRange(0, columnCount)) + return cursor + } + + override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor? { + return try { + val pipe = ParcelFileDescriptor.createPipe() + DownloadThread( + ParcelFileDescriptor.AutoCloseOutputStream(pipe[1]), + AppDatabase.getInstance(context!!) + ).start() + pipe[0] + } catch (e: IOException) { + throw FileNotFoundException("Could not open pipe") + } + } + + private class DownloadThread(private val outputStream: OutputStream, private val appDatabase: AppDatabase) : Thread() { + private val calendar = Calendar.getInstance(DateUtils.belgiumTimeZone, Locale.US) + // Format all times in GMT + private val dateFormat = SimpleDateFormat("yyyyMMdd'T'HHmmss'Z'", Locale.US).apply { + timeZone = TimeZone.getTimeZone("GMT+0") + } + private val dtStamp = dateFormat.format(System.currentTimeMillis()) + + override fun run() { + try { + ICalendarWriter(outputStream.sink().buffer()).use { writer -> + val bookmarks = appDatabase.bookmarksDao.getBookmarks() + writer.write("BEGIN", "VCALENDAR") + writer.write("VERSION", "2.0") + writer.write("PRODID", "-//${BuildConfig.APPLICATION_ID}//NONSGML ${BuildConfig.VERSION_NAME}//EN") + + for (event in bookmarks) { + writeEvent(writer, event) + } + + writer.write("END", "VCALENDAR") + } + } catch (ignore: Exception) { + } + } + + @Throws(IOException::class) + private fun writeEvent(writer: ICalendarWriter, event: Event) = with(writer) { + write("BEGIN", "VEVENT") + + val year = DateUtils.getYear(event.day.date.time, calendar) + write("UID", "${event.id}@$year@${BuildConfig.APPLICATION_ID}") + write("DTSTAMP", dtStamp) + event.startTime?.let { write("DTSTART", dateFormat.format(it)) } + event.endTime?.let { write("DTEND", dateFormat.format(it)) } + write("SUMMARY", event.title) + var description = event.abstractText + if (description.isNullOrEmpty()) { + description = event.description + } + if (!description.isNullOrEmpty()) { + write("DESCRIPTION", description.stripHtml()) + write("X-ALT-DESC", description) + } + write("CLASS", "PUBLIC") + write("CATEGORIES", event.track.name) + write("URL", event.url) + write("LOCATION", event.roomName) + + if (event.personsSummary != null) { + for (name in event.personsSummary.split(", ")) { + val key = "ATTENDEE;ROLE=REQ-PARTICIPANT;CUTYPE=INDIVIDUAL;CN=\"$name\"" + val url = FosdemUrls.getPerson(name.toSlug(), year) + write(key, url) + } + } + + write("END", "VEVENT") + } + } + + companion object { + private val URI = Uri.Builder() + .scheme("content") + .authority("${BuildConfig.APPLICATION_ID}.bookmarks") + .appendPath("bookmarks.ics") + .build() + private const val TYPE = "text/calendar" + private val COLUMNS = arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE) + + fun getIntent(activity: Activity?): Intent { + // Supports granting read permission for the attached shared file + return ShareCompat.IntentBuilder.from(activity!!) + .setStream(URI) + .setType(TYPE) + .intent + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/providers/SearchSuggestionProvider.java b/app/src/main/java/be/digitalia/fosdem/providers/SearchSuggestionProvider.java deleted file mode 100644 index a8d1be0..0000000 --- a/app/src/main/java/be/digitalia/fosdem/providers/SearchSuggestionProvider.java +++ /dev/null @@ -1,64 +0,0 @@ -package be.digitalia.fosdem.providers; - -import android.app.SearchManager; -import android.content.ContentProvider; -import android.content.ContentValues; -import android.database.Cursor; -import android.net.Uri; -import android.text.TextUtils; -import androidx.annotation.NonNull; -import be.digitalia.fosdem.db.AppDatabase; - -/** - * Simple content provider responsible for search suggestions. - * - * @author Christophe Beyls - */ -public class SearchSuggestionProvider extends ContentProvider { - - private static final int MIN_QUERY_LENGTH = 3; - private static final int DEFAULT_MAX_RESULTS = 5; - - @Override - public boolean onCreate() { - return true; - } - - @Override - public int delete(@NonNull Uri uri, String selection, String[] selectionArgs) { - throw new UnsupportedOperationException(); - } - - @Override - public Uri insert(@NonNull Uri uri, ContentValues values) { - throw new UnsupportedOperationException(); - } - - @Override - public int update(@NonNull Uri uri, ContentValues values, String selection, String[] selectionArgs) { - throw new UnsupportedOperationException(); - } - - @Override - public String getType(@NonNull Uri uri) { - return SearchManager.SUGGEST_MIME_TYPE; - } - - @Override - public Cursor query(@NonNull Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) { - String query = uri.getLastPathSegment(); - // Ignore empty or too small queries - if (query == null) { - return null; - } - query = query.trim(); - if ((query.length() < MIN_QUERY_LENGTH) || "search_suggest_query".equals(query)) { - return null; - } - - String limitParam = uri.getQueryParameter("limit"); - int limit = TextUtils.isEmpty(limitParam) ? DEFAULT_MAX_RESULTS : Integer.parseInt(limitParam); - - return AppDatabase.getInstance(getContext()).getScheduleDao().getSearchSuggestionResults(query, limit); - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/providers/SearchSuggestionProvider.kt b/app/src/main/java/be/digitalia/fosdem/providers/SearchSuggestionProvider.kt new file mode 100644 index 0000000..43911e4 --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/providers/SearchSuggestionProvider.kt @@ -0,0 +1,55 @@ +package be.digitalia.fosdem.providers + +import android.app.SearchManager +import android.content.ContentProvider +import android.content.ContentValues +import android.database.Cursor +import android.net.Uri +import be.digitalia.fosdem.db.AppDatabase + +/** + * Simple content provider responsible for search suggestions. + * + * @author Christophe Beyls + */ +class SearchSuggestionProvider : ContentProvider() { + + override fun onCreate(): Boolean { + return true + } + + override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int { + throw UnsupportedOperationException() + } + + override fun insert(uri: Uri, values: ContentValues?): Uri? { + throw UnsupportedOperationException() + } + + override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array?): Int { + throw UnsupportedOperationException() + } + + override fun getType(uri: Uri): String? { + return SearchManager.SUGGEST_MIME_TYPE + } + + override fun query(uri: Uri, projection: Array?, selection: String?, selectionArgs: Array?, sortOrder: String?): Cursor? { + var query = uri.lastPathSegment ?: return null + // Ignore empty or too small queries + query = query.trim() + if (query.length < MIN_QUERY_LENGTH || query == "search_suggest_query") { + return null + } + + val limitParam = uri.getQueryParameter("limit") + val limit = if (limitParam.isNullOrEmpty()) DEFAULT_MAX_RESULTS else limitParam.toInt() + + return AppDatabase.getInstance(context!!).scheduleDao.getSearchSuggestionResults(query, limit) + } + + companion object { + private const val MIN_QUERY_LENGTH = 3 + private const val DEFAULT_MAX_RESULTS = 5 + } +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/receivers/AlarmReceiver.java b/app/src/main/java/be/digitalia/fosdem/receivers/AlarmReceiver.java deleted file mode 100644 index ec14c9e..0000000 --- a/app/src/main/java/be/digitalia/fosdem/receivers/AlarmReceiver.java +++ /dev/null @@ -1,40 +0,0 @@ -package be.digitalia.fosdem.receivers; - -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; - -import be.digitalia.fosdem.BuildConfig; -import be.digitalia.fosdem.alarms.FosdemAlarmManager; -import be.digitalia.fosdem.services.AlarmIntentService; - -/** - * Entry point for system-generated events: boot complete and alarms. - * - * @author Christophe Beyls - */ -public class AlarmReceiver extends BroadcastReceiver { - - public static final String ACTION_NOTIFY_EVENT = BuildConfig.APPLICATION_ID + ".action.NOTIFY_EVENT"; - - @Override - public void onReceive(Context context, Intent intent) { - String action = intent.getAction(); - - if (ACTION_NOTIFY_EVENT.equals(action)) { - - // Forward the intent to the AlarmIntentService for background processing of the notification - Intent serviceIntent = new Intent(ACTION_NOTIFY_EVENT) - .setData(intent.getData()); - AlarmIntentService.enqueueWork(context, serviceIntent); - - } else if (Intent.ACTION_BOOT_COMPLETED.equals(action)) { - - String serviceAction = FosdemAlarmManager.getInstance().isEnabled() - ? AlarmIntentService.ACTION_UPDATE_ALARMS : AlarmIntentService.ACTION_DISABLE_ALARMS; - Intent serviceIntent = new Intent(serviceAction); - AlarmIntentService.enqueueWork(context, serviceIntent); - } - } - -} diff --git a/app/src/main/java/be/digitalia/fosdem/receivers/AlarmReceiver.kt b/app/src/main/java/be/digitalia/fosdem/receivers/AlarmReceiver.kt new file mode 100644 index 0000000..4fb1130 --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/receivers/AlarmReceiver.kt @@ -0,0 +1,36 @@ +package be.digitalia.fosdem.receivers + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import be.digitalia.fosdem.BuildConfig +import be.digitalia.fosdem.alarms.FosdemAlarmManager +import be.digitalia.fosdem.services.AlarmIntentService + +/** + * Entry point for system-generated events: boot complete and alarms. + * + * @author Christophe Beyls + */ +class AlarmReceiver : BroadcastReceiver() { + + override fun onReceive(context: Context, intent: Intent) { + when (intent.action) { + ACTION_NOTIFY_EVENT -> { + val serviceIntent = Intent(ACTION_NOTIFY_EVENT) + .setData(intent.data) + AlarmIntentService.enqueueWork(context, serviceIntent) + } + Intent.ACTION_BOOT_COMPLETED -> { + val serviceAction = if (FosdemAlarmManager.isEnabled) AlarmIntentService.ACTION_UPDATE_ALARMS + else AlarmIntentService.ACTION_DISABLE_ALARMS + val serviceIntent = Intent(serviceAction) + AlarmIntentService.enqueueWork(context, serviceIntent) + } + } + } + + companion object { + const val ACTION_NOTIFY_EVENT = BuildConfig.APPLICATION_ID + ".action.NOTIFY_EVENT" + } +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/services/AlarmIntentService.java b/app/src/main/java/be/digitalia/fosdem/services/AlarmIntentService.java deleted file mode 100644 index bf169a1..0000000 --- a/app/src/main/java/be/digitalia/fosdem/services/AlarmIntentService.java +++ /dev/null @@ -1,293 +0,0 @@ -package be.digitalia.fosdem.services; - -import android.app.AlarmManager; -import android.app.Notification; -import android.app.NotificationChannel; -import android.app.NotificationManager; -import android.app.PendingIntent; -import android.content.ComponentName; -import android.content.Context; -import android.content.Intent; -import android.content.SharedPreferences; -import android.content.pm.PackageManager; -import android.graphics.Typeface; -import android.net.Uri; -import android.os.Build; -import android.provider.Settings; -import android.text.Spannable; -import android.text.SpannableString; -import android.text.TextUtils; -import android.text.format.DateUtils; -import android.text.style.StyleSpan; - -import androidx.annotation.NonNull; -import androidx.annotation.RequiresApi; -import androidx.core.app.AlarmManagerCompat; -import androidx.core.app.JobIntentService; -import androidx.core.app.NotificationCompat; -import androidx.core.app.NotificationManagerCompat; -import androidx.core.app.TaskStackBuilder; -import androidx.core.content.ContextCompat; -import androidx.preference.PreferenceManager; - -import be.digitalia.fosdem.BuildConfig; -import be.digitalia.fosdem.R; -import be.digitalia.fosdem.activities.EventDetailsActivity; -import be.digitalia.fosdem.activities.MainActivity; -import be.digitalia.fosdem.activities.RoomImageDialogActivity; -import be.digitalia.fosdem.db.AppDatabase; -import be.digitalia.fosdem.fragments.SettingsFragment; -import be.digitalia.fosdem.model.AlarmInfo; -import be.digitalia.fosdem.model.Event; -import be.digitalia.fosdem.receivers.AlarmReceiver; -import be.digitalia.fosdem.utils.StringUtils; - -/** - * A service to schedule or unschedule alarms in the background, keeping the app responsive. - * - * @author Christophe Beyls - */ -public class AlarmIntentService extends JobIntentService { - - /** - * Unique job ID for this service. - */ - private static final int JOB_ID = 1000; - private static final String NOTIFICATION_CHANNEL = "event_alarms"; - - public static final String ACTION_UPDATE_ALARMS = BuildConfig.APPLICATION_ID + ".action.UPDATE_ALARMS"; - public static final String ACTION_DISABLE_ALARMS = BuildConfig.APPLICATION_ID + ".action.DISABLE_ALARMS"; - public static final String ACTION_ADD_BOOKMARK = BuildConfig.APPLICATION_ID + ".action.ADD_BOOKMARK"; - public static final String EXTRA_EVENT_ID = "event_id"; - public static final String EXTRA_EVENT_START_TIME = "event_start"; - public static final String ACTION_REMOVE_BOOKMARKS = BuildConfig.APPLICATION_ID + ".action.REMOVE_BOOKMARKS"; - public static final String EXTRA_EVENT_IDS = "event_ids"; - - - private AlarmManager alarmManager; - - /** - * Convenience method for enqueuing work in to this service. - */ - public static void enqueueWork(Context context, Intent work) { - enqueueWork(context, AlarmIntentService.class, JOB_ID, work); - } - - @Override - public void onCreate() { - super.onCreate(); - alarmManager = (AlarmManager) getSystemService(Context.ALARM_SERVICE); - } - - private PendingIntent getAlarmPendingIntent(long eventId) { - Intent intent = new Intent(this, AlarmReceiver.class) - .setAction(AlarmReceiver.ACTION_NOTIFY_EVENT) - .setData(Uri.parse(String.valueOf(eventId))); - return PendingIntent.getBroadcast(this, 0, intent, 0); - } - - @Override - protected void onHandleWork(@NonNull Intent intent) { - switch (intent.getAction()) { - - case ACTION_UPDATE_ALARMS: { - - // Create/update all alarms - final long delay = getDelay(); - final long now = System.currentTimeMillis(); - boolean hasAlarms = false; - for (AlarmInfo info : AppDatabase.getInstance(this).getBookmarksDao().getBookmarksAlarmInfo(0L)) { - final long notificationTime = info.getStartTime() == null ? -1L : info.getStartTime().getTime() - delay; - PendingIntent pi = getAlarmPendingIntent(info.getEventId()); - if (notificationTime < now) { - // Cancel pending alarms that are now scheduled in the past, if any - alarmManager.cancel(pi); - } else { - AlarmManagerCompat.setExact(alarmManager, AlarmManager.RTC_WAKEUP, notificationTime, pi); - hasAlarms = true; - } - } - setAlarmReceiverEnabled(hasAlarms); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && hasAlarms) { - createNotificationChannel(this); - } - - break; - } - case ACTION_DISABLE_ALARMS: { - - // Cancel alarms of every bookmark in the future - for (AlarmInfo info : AppDatabase.getInstance(this).getBookmarksDao().getBookmarksAlarmInfo(System.currentTimeMillis())) { - alarmManager.cancel(getAlarmPendingIntent(info.getEventId())); - } - setAlarmReceiverEnabled(false); - - break; - } - case ACTION_ADD_BOOKMARK: { - - long delay = getDelay(); - long eventId = intent.getLongExtra(EXTRA_EVENT_ID, -1L); - long startTime = intent.getLongExtra(EXTRA_EVENT_START_TIME, -1L); - // Only schedule future events. If they start before the delay, the alarm will go off immediately - if ((startTime == -1L) || (startTime < System.currentTimeMillis())) { - break; - } - setAlarmReceiverEnabled(true); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - createNotificationChannel(this); - } - AlarmManagerCompat.setExact(alarmManager, AlarmManager.RTC_WAKEUP, startTime - delay, getAlarmPendingIntent(eventId)); - - break; - } - case ACTION_REMOVE_BOOKMARKS: { - - // Cancel matching alarms, might they exist or not - long[] eventIds = intent.getLongArrayExtra(EXTRA_EVENT_IDS); - for (long eventId : eventIds) { - alarmManager.cancel(getAlarmPendingIntent(eventId)); - } - - break; - } - case AlarmReceiver.ACTION_NOTIFY_EVENT: { - - long eventId = Long.parseLong(intent.getDataString()); - Event event = AppDatabase.getInstance(this).getScheduleDao().getEvent(eventId); - if (event != null) { - NotificationManagerCompat.from(this).notify((int) eventId, buildNotification(event)); - } - - break; - } - } - } - - private long getDelay() { - String delayString = PreferenceManager.getDefaultSharedPreferences(getApplicationContext()).getString( - SettingsFragment.KEY_PREF_NOTIFICATIONS_DELAY, "0"); - // Convert from minutes to milliseconds - return Long.parseLong(delayString) * DateUtils.MINUTE_IN_MILLIS; - } - - /** - * Allows disabling the Alarm Receiver so the app is not loaded at boot when it's not necessary. - */ - private void setAlarmReceiverEnabled(boolean isEnabled) { - ComponentName componentName = new ComponentName(this, AlarmReceiver.class); - int flag = isEnabled ? PackageManager.COMPONENT_ENABLED_STATE_DEFAULT : PackageManager.COMPONENT_ENABLED_STATE_DISABLED; - getPackageManager().setComponentEnabledSetting(componentName, flag, PackageManager.DONT_KILL_APP); - } - - @RequiresApi(api = Build.VERSION_CODES.O) - private static void createNotificationChannel(Context context) { - NotificationManager notificationManager = context.getSystemService(NotificationManager.class); - NotificationChannel channel = new NotificationChannel(NOTIFICATION_CHANNEL, - context.getString(R.string.notification_events_channel_name), - NotificationManager.IMPORTANCE_HIGH); - channel.setShowBadge(false); - channel.setLightColor(ContextCompat.getColor(context, R.color.light_color_primary)); - channel.enableVibration(true); - channel.setLockscreenVisibility(Notification.VISIBILITY_PUBLIC); - notificationManager.createNotificationChannel(channel); - } - - @RequiresApi(api = Build.VERSION_CODES.O) - public static void startChannelNotificationSettingsActivity(Context context) { - createNotificationChannel(context); - - Intent intent = new Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS) - .putExtra(Settings.EXTRA_CHANNEL_ID, NOTIFICATION_CHANNEL) - .putExtra(Settings.EXTRA_APP_PACKAGE, context.getPackageName()); - context.startActivity(intent); - } - - private Notification buildNotification(Event event) { - PendingIntent eventPendingIntent = TaskStackBuilder - .create(this) - .addNextIntent(new Intent(this, MainActivity.class)) - .addNextIntent( - new Intent(this, EventDetailsActivity.class) - .setData(Uri.parse(String.valueOf(event.getId()))) - ) - .getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT); - - int defaultFlags = Notification.DEFAULT_SOUND; - SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(getApplicationContext()); - if (sharedPreferences.getBoolean(SettingsFragment.KEY_PREF_NOTIFICATIONS_VIBRATE, false)) { - defaultFlags |= Notification.DEFAULT_VIBRATE; - } - - String personsSummary = event.getPersonsSummary(); - String trackName = event.getTrack().getName(); - String contentText; - CharSequence bigText; - if (TextUtils.isEmpty(personsSummary)) { - contentText = trackName; - bigText = event.getSubTitle(); - } else { - contentText = String.format("%1$s - %2$s", trackName, personsSummary); - String subTitle = event.getSubTitle(); - SpannableString spannableBigText; - if (TextUtils.isEmpty(subTitle)) { - spannableBigText = new SpannableString(personsSummary); - } else { - spannableBigText = new SpannableString(String.format("%1$s\n%2$s", subTitle, personsSummary)); - } - // Set the persons summary in italic - spannableBigText.setSpan(new StyleSpan(Typeface.ITALIC), - spannableBigText.length() - personsSummary.length(), spannableBigText.length(), - Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); - bigText = spannableBigText; - } - - int notificationColor = ContextCompat.getColor(this, R.color.light_color_primary); - - NotificationCompat.Builder notificationBuilder = new NotificationCompat.Builder(this, NOTIFICATION_CHANNEL) - .setSmallIcon(R.drawable.ic_stat_fosdem) - .setColor(notificationColor) - .setWhen(event.getStartTime().getTime()) - .setContentTitle(event.getTitle()) - .setContentText(contentText) - .setStyle(new NotificationCompat.BigTextStyle().bigText(bigText).setSummaryText(trackName)) - .setContentInfo(event.getRoomName()) - .setContentIntent(eventPendingIntent) - .setAutoCancel(true) - .setDefaults(defaultFlags) - .setPriority(NotificationCompat.PRIORITY_HIGH) - .setCategory(NotificationCompat.CATEGORY_EVENT) - .setVisibility(NotificationCompat.VISIBILITY_PUBLIC); - - // Blink the LED with FOSDEM color if enabled in the options - if (sharedPreferences.getBoolean(SettingsFragment.KEY_PREF_NOTIFICATIONS_LED, false)) { - notificationBuilder.setLights(notificationColor, 1000, 5000); - } - - // Android Wear extensions - NotificationCompat.WearableExtender wearableExtender = new NotificationCompat.WearableExtender(); - - // Add an optional action button to show the room map image - String roomName = event.getRoomName(); - int roomImageResId = getResources().getIdentifier(StringUtils.roomNameToResourceName(roomName), - "drawable", getPackageName()); - if (roomImageResId != 0) { - // The room name is the unique Id of a RoomImageDialogActivity - Intent mapIntent = new Intent(this, RoomImageDialogActivity.class).setFlags( - Intent.FLAG_ACTIVITY_NEW_TASK).setData(Uri.parse(roomName)) - .putExtra(RoomImageDialogActivity.EXTRA_ROOM_NAME, roomName) - .putExtra(RoomImageDialogActivity.EXTRA_ROOM_IMAGE_RESOURCE_ID, roomImageResId); - PendingIntent mapPendingIntent = PendingIntent.getActivity(this, 0, mapIntent, - PendingIntent.FLAG_UPDATE_CURRENT); - CharSequence mapTitle = getString(R.string.room_map); - notificationBuilder.addAction(new NotificationCompat.Action(R.drawable.ic_place_white_24dp, mapTitle, - mapPendingIntent)); - // Use bigger action icon for wearable notification - wearableExtender.addAction(new NotificationCompat.Action(R.drawable.ic_place_white_wear, mapTitle, - mapPendingIntent)); - } - - notificationBuilder.extend(wearableExtender); - return notificationBuilder.build(); - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/services/AlarmIntentService.kt b/app/src/main/java/be/digitalia/fosdem/services/AlarmIntentService.kt new file mode 100644 index 0000000..3a568cd --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/services/AlarmIntentService.kt @@ -0,0 +1,258 @@ +package be.digitalia.fosdem.services + +import android.app.* +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.graphics.Typeface +import android.os.Build +import android.provider.Settings +import android.text.SpannableString +import android.text.format.DateUtils +import android.text.style.StyleSpan +import androidx.annotation.RequiresApi +import androidx.core.app.* +import androidx.core.app.TaskStackBuilder +import androidx.core.content.ContextCompat +import androidx.core.content.getSystemService +import androidx.core.net.toUri +import androidx.core.text.set +import androidx.preference.PreferenceManager +import be.digitalia.fosdem.BuildConfig +import be.digitalia.fosdem.R +import be.digitalia.fosdem.activities.EventDetailsActivity +import be.digitalia.fosdem.activities.MainActivity +import be.digitalia.fosdem.activities.RoomImageDialogActivity +import be.digitalia.fosdem.db.AppDatabase +import be.digitalia.fosdem.model.Event +import be.digitalia.fosdem.receivers.AlarmReceiver +import be.digitalia.fosdem.utils.PreferenceKeys +import be.digitalia.fosdem.utils.roomNameToResourceName + +/** + * A service to schedule or unschedule alarms in the background, keeping the app responsive. + * + * @author Christophe Beyls + */ +class AlarmIntentService : JobIntentService() { + + private val alarmManager by lazy { + getSystemService()!! + } + + private fun getAlarmPendingIntent(eventId: Long): PendingIntent { + val intent = Intent(this, AlarmReceiver::class.java) + .setAction(AlarmReceiver.ACTION_NOTIFY_EVENT) + .setData(eventId.toString().toUri()) + return PendingIntent.getBroadcast(this, 0, intent, 0) + } + + override fun onHandleWork(intent: Intent) { + when (intent.action) { + ACTION_UPDATE_ALARMS -> { + // Create/update all alarms + val delay = delay + val now = System.currentTimeMillis() + var hasAlarms = false + for (info in AppDatabase.getInstance(this).bookmarksDao.getBookmarksAlarmInfo(0L)) { + val startTime = info.startTime + val notificationTime = if (startTime == null) -1L else startTime.time - delay + val pi = getAlarmPendingIntent(info.eventId) + if (notificationTime < now) { + // Cancel pending alarms that are now scheduled in the past, if any + alarmManager.cancel(pi) + } else { + AlarmManagerCompat.setExact(alarmManager, AlarmManager.RTC_WAKEUP, notificationTime, pi) + hasAlarms = true + } + } + setAlarmReceiverEnabled(hasAlarms) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && hasAlarms) { + createNotificationChannel(this) + } + } + ACTION_DISABLE_ALARMS -> { + // Cancel alarms of every bookmark in the future + for (info in AppDatabase.getInstance(this).bookmarksDao.getBookmarksAlarmInfo(System.currentTimeMillis())) { + alarmManager.cancel(getAlarmPendingIntent(info.eventId)) + } + setAlarmReceiverEnabled(false) + } + ACTION_ADD_BOOKMARK -> { + val delay = delay + val eventId = intent.getLongExtra(EXTRA_EVENT_ID, -1L) + val startTime = intent.getLongExtra(EXTRA_EVENT_START_TIME, -1L) + // Only schedule future events. If they start before the delay, the alarm will go off immediately + if (startTime != -1L && startTime >= System.currentTimeMillis()) { + setAlarmReceiverEnabled(true) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + createNotificationChannel(this) + } + AlarmManagerCompat.setExact(alarmManager, AlarmManager.RTC_WAKEUP, startTime - delay, getAlarmPendingIntent(eventId)) + } + } + ACTION_REMOVE_BOOKMARKS -> { + // Cancel matching alarms, might they exist or not + val eventIds = intent.getLongArrayExtra(EXTRA_EVENT_IDS)!! + for (eventId in eventIds) { + alarmManager.cancel(getAlarmPendingIntent(eventId)) + } + } + AlarmReceiver.ACTION_NOTIFY_EVENT -> { + val eventId = intent.dataString!!.toLong() + val event = AppDatabase.getInstance(this).scheduleDao.getEvent(eventId) + if (event != null) { + NotificationManagerCompat.from(this).notify(eventId.toInt(), buildNotification(event)) + } + } + } + } + + // Convert from minutes to milliseconds + private val delay: Long + get() { + val delayString = PreferenceManager.getDefaultSharedPreferences(applicationContext) + .getString(PreferenceKeys.NOTIFICATIONS_DELAY, "0")!! + // Convert from minutes to milliseconds + return delayString.toLong() * DateUtils.MINUTE_IN_MILLIS + } + + /** + * Allows disabling the Alarm Receiver so the app is not loaded at boot when it's not necessary. + */ + private fun setAlarmReceiverEnabled(isEnabled: Boolean) { + val componentName = ComponentName(this, AlarmReceiver::class.java) + val flag = if (isEnabled) PackageManager.COMPONENT_ENABLED_STATE_DEFAULT else PackageManager.COMPONENT_ENABLED_STATE_DISABLED + packageManager.setComponentEnabledSetting(componentName, flag, PackageManager.DONT_KILL_APP) + } + + private fun buildNotification(event: Event): Notification { + val eventPendingIntent = TaskStackBuilder + .create(this) + .addNextIntent(Intent(this, MainActivity::class.java)) + .addNextIntent( + Intent(this, EventDetailsActivity::class.java) + .setData(event.id.toString().toUri()) + ) + .getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT) + + var defaultFlags = Notification.DEFAULT_SOUND + val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(applicationContext) + if (sharedPreferences.getBoolean(PreferenceKeys.NOTIFICATIONS_VIBRATE, false)) { + defaultFlags = defaultFlags or Notification.DEFAULT_VIBRATE + } + + val personsSummary = event.personsSummary + val trackName = event.track.name + val contentText: String + val bigText: CharSequence? + if (personsSummary.isNullOrEmpty()) { + contentText = trackName + bigText = event.subTitle + } else { + contentText = "$trackName - $personsSummary" + val subTitle = event.subTitle + val spannableBigText = if (subTitle.isNullOrEmpty()) { + SpannableString(personsSummary) + } else { + SpannableString("$subTitle\n$personsSummary") + } + // Set the persons summary in italic + spannableBigText[spannableBigText.length - personsSummary.length, spannableBigText.length] = StyleSpan(Typeface.ITALIC) + bigText = spannableBigText + } + + val notificationColor = ContextCompat.getColor(this, R.color.light_color_primary) + + val notificationBuilder = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL) + .setSmallIcon(R.drawable.ic_stat_fosdem) + .setColor(notificationColor) + .setWhen(event.startTime?.time ?: System.currentTimeMillis()) + .setContentTitle(event.title) + .setContentText(contentText) + .setStyle(NotificationCompat.BigTextStyle().bigText(bigText).setSummaryText(trackName)) + .setContentInfo(event.roomName) + .setContentIntent(eventPendingIntent) + .setAutoCancel(true) + .setDefaults(defaultFlags) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setCategory(NotificationCompat.CATEGORY_EVENT) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + + // Blink the LED with FOSDEM color if enabled in the options + if (sharedPreferences.getBoolean(PreferenceKeys.NOTIFICATIONS_LED, false)) { + notificationBuilder.setLights(notificationColor, 1000, 5000) + } + + // Android Wear extensions + val wearableExtender = NotificationCompat.WearableExtender() + + // Add an optional action button to show the room map image + val roomName = event.roomName + val roomImageResId = roomName?.let { resources.getIdentifier(roomNameToResourceName(it), "drawable", packageName) } + ?: 0 + if (roomName != null && roomImageResId != 0) { + // The room name is the unique Id of a RoomImageDialogActivity + val mapIntent = Intent(this, RoomImageDialogActivity::class.java) + .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + .setData(roomName.toUri()) + .putExtra(RoomImageDialogActivity.EXTRA_ROOM_NAME, roomName) + .putExtra(RoomImageDialogActivity.EXTRA_ROOM_IMAGE_RESOURCE_ID, roomImageResId) + val mapPendingIntent = PendingIntent.getActivity(this, 0, mapIntent, PendingIntent.FLAG_UPDATE_CURRENT) + val mapTitle = getString(R.string.room_map) + notificationBuilder.addAction(NotificationCompat.Action(R.drawable.ic_place_white_24dp, mapTitle, mapPendingIntent)) + // Use bigger action icon for wearable notification + wearableExtender.addAction(NotificationCompat.Action(R.drawable.ic_place_white_wear, mapTitle, mapPendingIntent)) + } + + notificationBuilder.extend(wearableExtender) + return notificationBuilder.build() + } + + companion object { + /** + * Unique job ID for this service. + */ + private const val JOB_ID = 1000 + private const val NOTIFICATION_CHANNEL = "event_alarms" + + const val ACTION_UPDATE_ALARMS = "${BuildConfig.APPLICATION_ID}.action.UPDATE_ALARMS" + const val ACTION_DISABLE_ALARMS = "${BuildConfig.APPLICATION_ID}.action.DISABLE_ALARMS" + const val ACTION_ADD_BOOKMARK = "${BuildConfig.APPLICATION_ID}.action.ADD_BOOKMARK" + const val EXTRA_EVENT_ID = "event_id" + const val EXTRA_EVENT_START_TIME = "event_start" + const val ACTION_REMOVE_BOOKMARKS = "${BuildConfig.APPLICATION_ID}.action.REMOVE_BOOKMARKS" + const val EXTRA_EVENT_IDS = "event_ids" + + /** + * Convenience method for enqueuing work in to this service. + */ + fun enqueueWork(context: Context, work: Intent) { + enqueueWork(context, AlarmIntentService::class.java, JOB_ID, work) + } + + @RequiresApi(api = Build.VERSION_CODES.O) + private fun createNotificationChannel(context: Context) { + val notificationManager: NotificationManager? = context.getSystemService() + val channel = NotificationChannel(NOTIFICATION_CHANNEL, + context.getString(R.string.notification_events_channel_name), + NotificationManager.IMPORTANCE_HIGH).apply { + setShowBadge(false) + lightColor = ContextCompat.getColor(context, R.color.light_color_primary) + enableVibration(true) + lockscreenVisibility = Notification.VISIBILITY_PUBLIC + } + notificationManager?.createNotificationChannel(channel) + } + + @RequiresApi(api = Build.VERSION_CODES.O) + fun startChannelNotificationSettingsActivity(context: Context) { + createNotificationChannel(context) + val intent = Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS) + .putExtra(Settings.EXTRA_CHANNEL_ID, NOTIFICATION_CHANNEL) + .putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) + context.startActivity(intent) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/utils/ByteCountSource.java b/app/src/main/java/be/digitalia/fosdem/utils/ByteCountSource.java deleted file mode 100644 index 434ef35..0000000 --- a/app/src/main/java/be/digitalia/fosdem/utils/ByteCountSource.java +++ /dev/null @@ -1,53 +0,0 @@ -package be.digitalia.fosdem.utils; - -import androidx.annotation.NonNull; - -import java.io.IOException; - -import okio.Buffer; -import okio.ForwardingSource; -import okio.Source; - -/** - * A Source which counts the total number of bytes read and notifies a listener. - * - * @author Christophe Beyls - */ -public class ByteCountSource extends ForwardingSource { - - public interface ByteCountListener { - void onNewCount(long byteCount); - } - - private final ByteCountListener listener; - private final long interval; - private long currentBytes = 0; - private long nextStepBytes; - - public ByteCountSource(@NonNull Source input, @NonNull ByteCountListener listener, long interval) { - super(input); - if (interval <= 0) { - throw new IllegalArgumentException("interval must be at least 1 byte"); - } - this.listener = listener; - this.interval = interval; - nextStepBytes = interval; - listener.onNewCount(0L); - } - - @Override - public long read(Buffer sink, long byteCount) throws IOException { - final long count = super.read(sink, byteCount); - - if (count != -1L) { - currentBytes += count; - if (currentBytes < nextStepBytes) { - return count; - } - nextStepBytes = currentBytes + interval; - } - listener.onNewCount(currentBytes); - - return count; - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/utils/ByteCountSource.kt b/app/src/main/java/be/digitalia/fosdem/utils/ByteCountSource.kt new file mode 100644 index 0000000..e03e69e --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/utils/ByteCountSource.kt @@ -0,0 +1,44 @@ +package be.digitalia.fosdem.utils + +import okio.Buffer +import okio.ForwardingSource +import okio.Source +import java.io.IOException + +/** + * A Source which counts the total number of bytes read and notifies a listener. + * + * @author Christophe Beyls + */ +class ByteCountSource(input: Source, + private val listener: ByteCountListener, + private val interval: Long) : ForwardingSource(input) { + + interface ByteCountListener { + fun onNewCount(byteCount: Long) + } + + private var currentBytes: Long = 0 + private var nextStepBytes: Long = interval + + init { + require(interval > 0L) { "interval must be at least 1 byte" } + listener.onNewCount(0L) + } + + @Throws(IOException::class) + override fun read(sink: Buffer, byteCount: Long): Long { + val count = super.read(sink, byteCount) + + if (count != -1L) { + currentBytes += count + if (currentBytes < nextStepBytes) { + return count + } + nextStepBytes = currentBytes + interval + } + listener.onNewCount(currentBytes) + + return count + } +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/utils/ClickableArrowKeyMovementMethod.java b/app/src/main/java/be/digitalia/fosdem/utils/ClickableArrowKeyMovementMethod.java deleted file mode 100644 index c6ab033..0000000 --- a/app/src/main/java/be/digitalia/fosdem/utils/ClickableArrowKeyMovementMethod.java +++ /dev/null @@ -1,63 +0,0 @@ -package be.digitalia.fosdem.utils; - -import android.graphics.RectF; -import android.text.Layout; -import android.text.Spannable; -import android.text.method.ArrowKeyMovementMethod; -import android.text.method.MovementMethod; -import android.text.style.ClickableSpan; -import android.view.MotionEvent; -import android.widget.TextView; - -/** - * An extension of ArrowKeyMovementMethod supporting clickable spans as well. - */ -public class ClickableArrowKeyMovementMethod extends ArrowKeyMovementMethod { - - private static ClickableArrowKeyMovementMethod instance; - - private final RectF touchedLineBounds = new RectF(); - - public static MovementMethod getInstance() { - if (instance == null) { - instance = new ClickableArrowKeyMovementMethod(); - } - return instance; - } - - @Override - public boolean onTouchEvent(TextView widget, Spannable buffer, MotionEvent event) { - // If action has finished - if (event.getAction() == MotionEvent.ACTION_UP) { - // Locate the area that was pressed - int x = (int) event.getX(); - int y = (int) event.getY(); - x -= widget.getTotalPaddingLeft(); - y -= widget.getTotalPaddingTop(); - x += widget.getScrollX(); - y += widget.getScrollY(); - - // Locate the text line - Layout layout = widget.getLayout(); - int line = layout.getLineForVertical(y); - - // Check that the touch actually happened within the line bounds - touchedLineBounds.left = layout.getLineLeft(line); - touchedLineBounds.top = layout.getLineTop(line); - touchedLineBounds.right = layout.getLineWidth(line) + touchedLineBounds.left; - touchedLineBounds.bottom = layout.getLineBottom(line); - - if (touchedLineBounds.contains(x, y)) { - int offset = layout.getOffsetForHorizontal(line, x); - // Find a clickable span at that text offset, if any - ClickableSpan[] link = buffer.getSpans(offset, offset, ClickableSpan.class); - if (link.length > 0) { - link[0].onClick(widget); - return true; - } - } - } - - return super.onTouchEvent(widget, buffer, event); - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/utils/ClickableArrowKeyMovementMethod.kt b/app/src/main/java/be/digitalia/fosdem/utils/ClickableArrowKeyMovementMethod.kt new file mode 100644 index 0000000..986a64e --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/utils/ClickableArrowKeyMovementMethod.kt @@ -0,0 +1,54 @@ +package be.digitalia.fosdem.utils + +import android.graphics.RectF +import android.text.Spannable +import android.text.method.ArrowKeyMovementMethod +import android.text.style.ClickableSpan +import android.view.MotionEvent +import android.widget.TextView +import androidx.core.text.getSpans + +/** + * An extension of ArrowKeyMovementMethod supporting clickable spans as well. + */ +object ClickableArrowKeyMovementMethod : ArrowKeyMovementMethod() { + + private val touchedLineBounds = RectF() + + override fun onTouchEvent(widget: TextView, buffer: Spannable, event: MotionEvent): Boolean { + // If action has finished + if (event.action == MotionEvent.ACTION_UP) { + // Locate the area that was pressed + var x = event.x.toInt() + var y = event.y.toInt() + x -= widget.totalPaddingLeft + y -= widget.totalPaddingTop + x += widget.scrollX + y += widget.scrollY + + // Locate the text line + val layout = widget.layout + val line = layout.getLineForVertical(y) + + // Check that the touch actually happened within the line bounds + with(touchedLineBounds) { + left = layout.getLineLeft(line) + top = layout.getLineTop(line).toFloat() + right = layout.getLineWidth(line) + left + bottom = layout.getLineBottom(line).toFloat() + } + + if (touchedLineBounds.contains(x.toFloat(), y.toFloat())) { + val offset = layout.getOffsetForHorizontal(line, x.toFloat()) + // Find a clickable span at that text offset, if any + val clickableSpans = buffer.getSpans(offset, offset) + if (clickableSpans.isNotEmpty()) { + clickableSpans[0].onClick(widget) + return true + } + } + } + + return super.onTouchEvent(widget, buffer, event) + } +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/utils/CustomTabsIntentExt.kt b/app/src/main/java/be/digitalia/fosdem/utils/CustomTabsIntentExt.kt new file mode 100644 index 0000000..6514cba --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/utils/CustomTabsIntentExt.kt @@ -0,0 +1,29 @@ +package be.digitalia.fosdem.utils + +import android.annotation.SuppressLint +import android.content.Context +import androidx.annotation.ColorRes +import androidx.appcompat.app.AppCompatDelegate +import androidx.browser.customtabs.CustomTabColorSchemeParams +import androidx.browser.customtabs.CustomTabsIntent +import androidx.core.content.ContextCompat +import be.digitalia.fosdem.R + +@SuppressLint("PrivateResource") +fun CustomTabsIntent.Builder.configureToolbarColors(context: Context, + @ColorRes toolbarColorResId: Int): CustomTabsIntent.Builder { + val darkColorSchemeParams = CustomTabColorSchemeParams.Builder() + .setToolbarColor(ContextCompat.getColor(context, R.color.design_dark_default_color_surface)) + .build() + + // Request the browser tab to follow the app theme setting + val colorScheme = when (AppCompatDelegate.getDefaultNightMode()) { + AppCompatDelegate.MODE_NIGHT_NO -> CustomTabsIntent.COLOR_SCHEME_LIGHT + AppCompatDelegate.MODE_NIGHT_YES -> CustomTabsIntent.COLOR_SCHEME_DARK + else -> CustomTabsIntent.COLOR_SCHEME_SYSTEM + } + + return setToolbarColor(ContextCompat.getColor(context, toolbarColorResId)) + .setColorSchemeParams(CustomTabsIntent.COLOR_SCHEME_DARK, darkColorSchemeParams) + .setColorScheme(colorScheme) +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/utils/CustomTabsUtils.java b/app/src/main/java/be/digitalia/fosdem/utils/CustomTabsUtils.java deleted file mode 100644 index 25b49db..0000000 --- a/app/src/main/java/be/digitalia/fosdem/utils/CustomTabsUtils.java +++ /dev/null @@ -1,42 +0,0 @@ -package be.digitalia.fosdem.utils; - -import android.annotation.SuppressLint; -import android.content.Context; - -import androidx.annotation.ColorRes; -import androidx.annotation.NonNull; -import androidx.appcompat.app.AppCompatDelegate; -import androidx.browser.customtabs.CustomTabColorSchemeParams; -import androidx.browser.customtabs.CustomTabsIntent; -import androidx.core.content.ContextCompat; -import be.digitalia.fosdem.R; - -public class CustomTabsUtils { - - @NonNull - @SuppressLint("PrivateResource") - public static CustomTabsIntent.Builder configureToolbarColors(@NonNull CustomTabsIntent.Builder builder, - @NonNull Context context, - @ColorRes int toolbarColorResId) { - final CustomTabColorSchemeParams darkColorSchemeParams = new CustomTabColorSchemeParams.Builder() - .setToolbarColor(ContextCompat.getColor(context, R.color.design_dark_default_color_surface)) - .build(); - - // Request the browser tab to follow the app theme setting - final int colorScheme; - switch (AppCompatDelegate.getDefaultNightMode()) { - case AppCompatDelegate.MODE_NIGHT_NO: - colorScheme = CustomTabsIntent.COLOR_SCHEME_LIGHT; - break; - case AppCompatDelegate.MODE_NIGHT_YES: - colorScheme = CustomTabsIntent.COLOR_SCHEME_DARK; - break; - default: - colorScheme = CustomTabsIntent.COLOR_SCHEME_SYSTEM; - } - - return builder.setToolbarColor(ContextCompat.getColor(context, toolbarColorResId)) - .setColorSchemeParams(CustomTabsIntent.COLOR_SCHEME_DARK, darkColorSchemeParams) - .setColorScheme(colorScheme); - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/utils/DateUtils.java b/app/src/main/java/be/digitalia/fosdem/utils/DateUtils.java deleted file mode 100644 index 36d3851..0000000 --- a/app/src/main/java/be/digitalia/fosdem/utils/DateUtils.java +++ /dev/null @@ -1,40 +0,0 @@ -package be.digitalia.fosdem.utils; - -import android.content.Context; - -import java.text.DateFormat; -import java.util.Calendar; -import java.util.Locale; -import java.util.TimeZone; - -import androidx.annotation.Nullable; - -public class DateUtils { - - private static final TimeZone BELGIUM_TIME_ZONE = TimeZone.getTimeZone("GMT+1"); - - public static TimeZone getBelgiumTimeZone() { - return BELGIUM_TIME_ZONE; - } - - public static DateFormat withBelgiumTimeZone(DateFormat format) { - format.setTimeZone(BELGIUM_TIME_ZONE); - return format; - } - - public static DateFormat getTimeDateFormat(Context context) { - return withBelgiumTimeZone(android.text.format.DateFormat.getTimeFormat(context)); - } - - public static int getYear(long timestamp) { - return getYear(timestamp, null); - } - - public static int getYear(long timestamp, @Nullable Calendar calendar) { - if (calendar == null) { - calendar = Calendar.getInstance(DateUtils.getBelgiumTimeZone(), Locale.US); - } - calendar.setTimeInMillis(timestamp); - return calendar.get(Calendar.YEAR); - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/utils/DateUtils.kt b/app/src/main/java/be/digitalia/fosdem/utils/DateUtils.kt new file mode 100644 index 0000000..c63e2d8 --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/utils/DateUtils.kt @@ -0,0 +1,24 @@ +package be.digitalia.fosdem.utils + +import android.content.Context +import java.text.DateFormat +import java.util.* + +object DateUtils { + val belgiumTimeZone: TimeZone = TimeZone.getTimeZone("GMT+1") + + fun DateFormat.withBelgiumTimeZone(): DateFormat { + timeZone = belgiumTimeZone + return this + } + + fun getTimeDateFormat(context: Context): DateFormat { + return android.text.format.DateFormat.getTimeFormat(context).withBelgiumTimeZone() + } + + fun getYear(timestamp: Long, calendar: Calendar? = null): Int { + val cal = calendar ?: Calendar.getInstance(belgiumTimeZone, Locale.US) + cal.timeInMillis = timestamp + return cal.get(Calendar.YEAR) + } +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/utils/DrawerLayoutExt.kt b/app/src/main/java/be/digitalia/fosdem/utils/DrawerLayoutExt.kt new file mode 100644 index 0000000..8ef5a74 --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/utils/DrawerLayoutExt.kt @@ -0,0 +1,26 @@ +package be.digitalia.fosdem.utils + +import android.view.View +import androidx.drawerlayout.widget.DrawerLayout +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume + +suspend fun DrawerLayout.awaitCloseDrawer(drawerView: View) = suspendCancellableCoroutine { cont -> + val listener = object : DrawerLayout.SimpleDrawerListener() { + override fun onDrawerStateChanged(newState: Int) { + if (newState == DrawerLayout.STATE_DRAGGING) { + cont.cancel() + } + } + + override fun onDrawerClosed(drawerView: View) { + removeDrawerListener(this) + cont.resume(Unit) + } + } + cont.invokeOnCancellation { + removeDrawerListener(listener) + } + addDrawerListener(listener) + closeDrawer(drawerView) +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/utils/ICalendarWriter.java b/app/src/main/java/be/digitalia/fosdem/utils/ICalendarWriter.java deleted file mode 100644 index 120a375..0000000 --- a/app/src/main/java/be/digitalia/fosdem/utils/ICalendarWriter.java +++ /dev/null @@ -1,58 +0,0 @@ -package be.digitalia.fosdem.utils; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import java.io.Closeable; -import java.io.IOException; - -import okio.BufferedSink; - -/** - * Simple wrapper to write to iCalendar file format. - */ -public class ICalendarWriter implements Closeable { - - private static final String CRLF = "\r\n"; - - private final BufferedSink sink; - - public ICalendarWriter(@NonNull BufferedSink sink) { - this.sink = sink; - } - - public void write(@NonNull String key, @Nullable String value) throws IOException { - if (value != null) { - sink.writeUtf8(key); - sink.writeUtf8CodePoint(':'); - - // Escape line break sequences - final int length = value.length(); - int start = 0; - int end = 0; - while (end < length) { - final char c = value.charAt(end); - if (c == '\r' || c == '\n') { - sink.writeUtf8(value, start, end); - sink.writeUtf8(CRLF); - sink.writeUtf8CodePoint(' '); - do { - end++; - } - while ((end < length) && (value.charAt(end) == '\r' || value.charAt(end) == '\n')); - start = end; - } else { - end++; - } - } - sink.writeUtf8(value, start, length); - - sink.writeUtf8(CRLF); - } - } - - @Override - public void close() throws IOException { - sink.close(); - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/utils/ICalendarWriter.kt b/app/src/main/java/be/digitalia/fosdem/utils/ICalendarWriter.kt new file mode 100644 index 0000000..be8effe --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/utils/ICalendarWriter.kt @@ -0,0 +1,53 @@ +package be.digitalia.fosdem.utils + +import okio.BufferedSink +import java.io.Closeable +import java.io.IOException + +/** + * Simple wrapper to write to iCalendar file format. + */ +class ICalendarWriter(private val sink: BufferedSink) : Closeable { + + @Throws(IOException::class) + fun write(key: String, value: String?) { + if (value != null) { + with(sink) { + writeUtf8(key) + writeUtf8CodePoint(':'.toInt()) + + // Escape line break sequences + val length = value.length + var start = 0 + var end = 0 + while (end < length) { + val c = value[end] + if (c == '\r' || c == '\n') { + writeUtf8(value, start, end) + writeUtf8(CRLF) + writeUtf8CodePoint(' '.toInt()) + do { + end++ + } while (end < length && (value[end] == '\r' || value[end] == '\n')) + start = end + } else { + end++ + } + } + writeUtf8(value, start, length) + + writeUtf8(CRLF) + } + } + } + + @Throws(IOException::class) + override fun close() { + sink.close() + } + + companion object { + private const val CRLF = "\r\n" + } + +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/utils/NfcUtils.java b/app/src/main/java/be/digitalia/fosdem/utils/NfcUtils.java deleted file mode 100644 index c94334d..0000000 --- a/app/src/main/java/be/digitalia/fosdem/utils/NfcUtils.java +++ /dev/null @@ -1,114 +0,0 @@ -package be.digitalia.fosdem.utils; - -import android.app.Activity; -import android.content.Context; -import android.content.Intent; -import android.nfc.NdefMessage; -import android.nfc.NdefRecord; -import android.nfc.NfcAdapter; -import android.os.Parcelable; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import be.digitalia.fosdem.model.Event; - -import java.nio.ByteBuffer; -import java.util.List; - -/** - * NFC helper methods. - * - * @author Christophe Beyls - */ -public class NfcUtils { - - /** - * Implement this interface to create application-specific data to be shared through Android Beam. - */ - public interface CreateNfcAppDataCallback { - /** - * @return The app data, or null if no data is currently available for sharing. - */ - @Nullable - NdefRecord createNfcAppData(); - } - - /** - * Call this method in an Activity, between onCreate() and onDestroy(), to make its content sharable using Android Beam if available. - * Declare the corresponding MIME type of the NDEF record it in your Manifest's intent filters as the data type with an action of - * android.nfc.action.NDEF_DISCOVERED to handle the NFC Intents on the receiver side. - * - * @return true if NFC is available and the content was made available, false if not. - */ - @SuppressWarnings("deprecation") - public static boolean setAppDataPushMessageCallbackIfAvailable(Activity activity, final CreateNfcAppDataCallback callback) { - NfcAdapter adapter = NfcAdapter.getDefaultAdapter(activity); - if (adapter == null) { - return false; - } - final String packageName = activity.getPackageName(); - adapter.setNdefPushMessageCallback(event -> { - final NdefRecord appData = callback.createNfcAppData(); - if (appData == null) { - return null; - } - NdefRecord[] records = new NdefRecord[]{appData, NdefRecord.createApplicationRecord(packageName)}; - return new NdefMessage(records); - }, activity); - return true; - } - - public static NdefRecord createEventAppData(@NonNull Context context, @NonNull Event event) { - String mimeType = "application/" + context.getPackageName(); - byte[] mimeData = String.valueOf(event.getId()).getBytes(); - return NdefRecord.createMime(mimeType, mimeData); - } - - public static String toEventIdString(@NonNull NdefRecord record) { - return new String(record.getPayload()); - } - - public static NdefRecord createBookmarksAppData(@NonNull Context context, List bookmarks) { - String mimeType = "application/" + context.getPackageName() + "-bookmarks"; - final int size = bookmarks.size(); - ByteBuffer buffer = ByteBuffer.allocate(4 + size * 8); - buffer.putInt(size); - for (int i = 0; i < size; ++i) { - buffer.putLong(bookmarks.get(i).getId()); - } - return NdefRecord.createMime(mimeType, buffer.array()); - } - - @Nullable - public static long[] toBookmarks(@NonNull NdefRecord ndefRecord) { - try { - ByteBuffer buffer = ByteBuffer.wrap(ndefRecord.getPayload()); - final int size = buffer.getInt(); - long[] bookmarks = new long[size]; - for (int i = 0; i < size; ++i) { - bookmarks[i] = buffer.getLong(); - } - return bookmarks; - } catch (Exception e) { - return null; - } - } - - /** - * Determines if the intent contains NFC NDEF application-specific data to be extracted. - */ - public static boolean hasAppData(Intent intent) { - return NfcAdapter.ACTION_NDEF_DISCOVERED.equals(intent.getAction()); - } - - /** - * Extracts application-specific data sent through NFC from an intent. - * You must first ensure that the intent contains NFC data by calling hasAppData(). - * - * @return The extracted app data as an NdefRecord - */ - public static NdefRecord extractAppData(Intent intent) { - Parcelable[] rawMsgs = intent.getParcelableArrayExtra(NfcAdapter.EXTRA_NDEF_MESSAGES); - NdefMessage msg = (NdefMessage) rawMsgs[0]; - return msg.getRecords()[0]; - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/utils/NfcUtils.kt b/app/src/main/java/be/digitalia/fosdem/utils/NfcUtils.kt new file mode 100644 index 0000000..5a64bbe --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/utils/NfcUtils.kt @@ -0,0 +1,96 @@ +@file:Suppress("DEPRECATION") + +package be.digitalia.fosdem.utils + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.nfc.NdefMessage +import android.nfc.NdefRecord +import android.nfc.NfcAdapter +import android.nfc.NfcAdapter.CreateNdefMessageCallback +import be.digitalia.fosdem.model.Event +import java.nio.ByteBuffer + +/** + * NFC helper methods. + * + * @author Christophe Beyls + */ + +/** + * Call this method in an Activity, between onCreate() and onDestroy(), to make its content shareable using Android Beam if available. + * Declare the corresponding MIME type of the NDEF record it in your Manifest's intent filters as the data type with an action of + * android.nfc.action.NDEF_DISCOVERED to handle the NFC Intents on the receiver side. + * + * @return true if NFC is available and the content was made available, false if not. + */ +fun Activity.setNfcAppDataPushMessageCallbackIfAvailable(callback: CreateNfcAppDataCallback): Boolean { + val adapter = NfcAdapter.getDefaultAdapter(applicationContext) ?: return false + val packageName = packageName + adapter.setNdefPushMessageCallback(CreateNdefMessageCallback { + val appData = callback.createNfcAppData() ?: return@CreateNdefMessageCallback null + val records = arrayOf(appData, NdefRecord.createApplicationRecord(packageName)) + NdefMessage(records) + }, this) + return true +} + +fun Event.toNfcAppData(context: Context): NdefRecord { + val mimeType = "application/${context.packageName}" + val mimeData = id.toString().toByteArray() + return NdefRecord.createMime(mimeType, mimeData) +} + +fun NdefRecord.toEventIdString(): String { + return String(payload) +} + +fun List.toBookmarksNfcAppData(context: Context): NdefRecord { + val mimeType = "application/${context.packageName}-bookmarks" + val size = size + val buffer = ByteBuffer.allocate(4 + size * 8) + buffer.putInt(size) + for (event in this) { + buffer.putLong(event.id) + } + return NdefRecord.createMime(mimeType, buffer.array()) +} + +fun NdefRecord.toBookmarks(): LongArray? { + return try { + val buffer = ByteBuffer.wrap(payload) + LongArray(buffer.int) { buffer.long } + } catch (e: Exception) { + null + } +} + +/** + * Determines if the intent contains NFC NDEF application-specific data to be extracted. + */ +fun Intent.hasNfcAppData(): Boolean { + return action == NfcAdapter.ACTION_NDEF_DISCOVERED +} + +/** + * Extracts application-specific data sent through NFC from an intent. + * You must first ensure that the intent contains NFC data by calling hasAppData(). + * + * @return The extracted app data as an NdefRecord + */ +fun Intent.extractNfcAppData(): NdefRecord { + val rawMsgs = getParcelableArrayExtra(NfcAdapter.EXTRA_NDEF_MESSAGES)!! + val msg = rawMsgs[0] as NdefMessage + return msg.records[0] +} + +/** + * Implement this interface to create application-specific data to be shared through Android Beam. + */ +interface CreateNfcAppDataCallback { + /** + * @return The app data, or null if no data is currently available for sharing. + */ + fun createNfcAppData(): NdefRecord? +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/utils/Parcelers.kt b/app/src/main/java/be/digitalia/fosdem/utils/Parcelers.kt new file mode 100644 index 0000000..05e23e2 --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/utils/Parcelers.kt @@ -0,0 +1,46 @@ +package be.digitalia.fosdem.utils + +import android.os.Parcel +import android.util.LongSparseArray +import androidx.core.util.forEach +import androidx.core.util.size +import kotlinx.android.parcel.Parceler +import java.util.* + +object DateParceler : Parceler { + + override fun create(parcel: Parcel): Date? { + val value = parcel.readLong() + return if (value == -1L) null else Date(value) + } + + override fun Date?.write(parcel: Parcel, flags: Int) = parcel.writeLong(this?.time ?: -1L) +} + +object IntLongSparseArrayParceler : Parceler?> { + + override fun create(parcel: Parcel): LongSparseArray? { + val size = parcel.readInt() + return if (size >= 0) { + LongSparseArray(size).apply { + for (i in 0 until size) { + val key = parcel.readLong() + val value = parcel.readInt() + append(key, value) + } + } + } else null + } + + override fun LongSparseArray?.write(parcel: Parcel, flags: Int) { + if (this == null) { + parcel.writeInt(-1) + } else { + parcel.writeInt(size) + forEach { key, value -> + parcel.writeLong(key) + parcel.writeInt(value) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/utils/PreferenceKeys.kt b/app/src/main/java/be/digitalia/fosdem/utils/PreferenceKeys.kt new file mode 100644 index 0000000..e9a3ad0 --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/utils/PreferenceKeys.kt @@ -0,0 +1,15 @@ +package be.digitalia.fosdem.utils + +object PreferenceKeys { + const val THEME = "theme" + const val NOTIFICATIONS_ENABLED = "notifications_enabled" + // Android >= O only + const val NOTIFICATIONS_CHANNEL = "notifications_channel" + // Android < O only + const val NOTIFICATIONS_VIBRATE = "notifications_vibrate" + // Android < O only + const val NOTIFICATIONS_LED = "notifications_led" + const val NOTIFICATIONS_DELAY = "notifications_delay" + const val ABOUT = "about" + const val VERSION = "version" +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/utils/RecyclerViewExt.kt b/app/src/main/java/be/digitalia/fosdem/utils/RecyclerViewExt.kt new file mode 100644 index 0000000..ca8fa50 --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/utils/RecyclerViewExt.kt @@ -0,0 +1,79 @@ +package be.digitalia.fosdem.utils + +import android.view.MotionEvent +import androidx.core.view.get +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.OnItemTouchListener +import androidx.viewpager2.widget.ViewPager2 +import kotlin.math.abs + +val ViewPager2.recyclerView: RecyclerView + get() { + return this[0] as RecyclerView + } + +fun RecyclerView.enforceSingleScrollDirection() { + val enforcer = SingleScrollDirectionEnforcer() + addOnItemTouchListener(enforcer) + addOnScrollListener(enforcer) +} + +private class SingleScrollDirectionEnforcer : RecyclerView.OnScrollListener(), OnItemTouchListener { + + private var scrollState = RecyclerView.SCROLL_STATE_IDLE + private var scrollPointerId = -1 + private var initialTouchX = 0 + private var initialTouchY = 0 + private var dx = 0 + private var dy = 0 + + override fun onInterceptTouchEvent(rv: RecyclerView, e: MotionEvent): Boolean { + when (e.actionMasked) { + MotionEvent.ACTION_DOWN -> { + scrollPointerId = e.getPointerId(0) + initialTouchX = (e.x + 0.5f).toInt() + initialTouchY = (e.y + 0.5f).toInt() + } + MotionEvent.ACTION_POINTER_DOWN -> { + val actionIndex = e.actionIndex + scrollPointerId = e.getPointerId(actionIndex) + initialTouchX = (e.getX(actionIndex) + 0.5f).toInt() + initialTouchY = (e.getY(actionIndex) + 0.5f).toInt() + } + MotionEvent.ACTION_MOVE -> { + val index = e.findPointerIndex(scrollPointerId) + if (index >= 0 && scrollState != RecyclerView.SCROLL_STATE_DRAGGING) { + val x = (e.getX(index) + 0.5f).toInt() + val y = (e.getY(index) + 0.5f).toInt() + dx = x - initialTouchX + dy = y - initialTouchY + } + } + } + return false + } + + override fun onTouchEvent(rv: RecyclerView, e: MotionEvent) {} + + override fun onRequestDisallowInterceptTouchEvent(disallowIntercept: Boolean) {} + + override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { + val oldState = scrollState + scrollState = newState + if (oldState == RecyclerView.SCROLL_STATE_IDLE && newState == RecyclerView.SCROLL_STATE_DRAGGING) { + val layoutManager = recyclerView.layoutManager + if (layoutManager != null) { + val canScrollHorizontally = layoutManager.canScrollHorizontally() + val canScrollVertically = layoutManager.canScrollVertically() + if (canScrollHorizontally != canScrollVertically) { + if (canScrollHorizontally && abs(dy) > abs(dx)) { + recyclerView.stopScroll() + } + if (canScrollVertically && abs(dx) > abs(dy)) { + recyclerView.stopScroll() + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/utils/RecyclerViewUtils.java b/app/src/main/java/be/digitalia/fosdem/utils/RecyclerViewUtils.java deleted file mode 100644 index 9998d4d..0000000 --- a/app/src/main/java/be/digitalia/fosdem/utils/RecyclerViewUtils.java +++ /dev/null @@ -1,91 +0,0 @@ -package be.digitalia.fosdem.utils; - -import android.view.MotionEvent; - -import androidx.annotation.NonNull; -import androidx.recyclerview.widget.RecyclerView; -import androidx.viewpager2.widget.ViewPager2; - -public class RecyclerViewUtils { - - @NonNull - public static RecyclerView getRecyclerView(@NonNull ViewPager2 viewPager) { - return (RecyclerView) viewPager.getChildAt(0); - } - - public static void enforceSingleScrollDirection(@NonNull RecyclerView recyclerView) { - final SingleScrollDirectionEnforcer enforcer = new SingleScrollDirectionEnforcer(); - recyclerView.addOnItemTouchListener(enforcer); - recyclerView.addOnScrollListener(enforcer); - } - - private static class SingleScrollDirectionEnforcer extends RecyclerView.OnScrollListener - implements RecyclerView.OnItemTouchListener { - - private int scrollState = RecyclerView.SCROLL_STATE_IDLE; - private int scrollPointerId = -1; - private int initialTouchX; - private int initialTouchY; - private int dx; - private int dy; - - @Override - public boolean onInterceptTouchEvent(@NonNull RecyclerView rv, @NonNull MotionEvent e) { - final int action = e.getActionMasked(); - switch (action) { - case MotionEvent.ACTION_DOWN: - scrollPointerId = e.getPointerId(0); - initialTouchX = (int) (e.getX() + 0.5f); - initialTouchY = (int) (e.getY() + 0.5f); - break; - - case MotionEvent.ACTION_POINTER_DOWN: - final int actionIndex = e.getActionIndex(); - scrollPointerId = e.getPointerId(actionIndex); - initialTouchX = (int) (e.getX(actionIndex) + 0.5f); - initialTouchY = (int) (e.getY(actionIndex) + 0.5f); - break; - - case MotionEvent.ACTION_MOVE: { - final int index = e.findPointerIndex(scrollPointerId); - if (index >= 0 && scrollState != RecyclerView.SCROLL_STATE_DRAGGING) { - final int x = (int) (e.getX(index) + 0.5f); - final int y = (int) (e.getY(index) + 0.5f); - dx = x - initialTouchX; - dy = y - initialTouchY; - } - } - } - return false; - } - - @Override - public void onTouchEvent(@NonNull RecyclerView rv, @NonNull MotionEvent e) { - } - - @Override - public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) { - } - - @Override - public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) { - int oldState = scrollState; - scrollState = newState; - if (oldState == RecyclerView.SCROLL_STATE_IDLE && newState == RecyclerView.SCROLL_STATE_DRAGGING) { - final RecyclerView.LayoutManager layoutManager = recyclerView.getLayoutManager(); - if (layoutManager != null) { - final boolean canScrollHorizontally = layoutManager.canScrollHorizontally(); - final boolean canScrollVertically = layoutManager.canScrollVertically(); - if (canScrollHorizontally != canScrollVertically) { - if (canScrollHorizontally && Math.abs(dy) > Math.abs(dx)) { - recyclerView.stopScroll(); - } - if (canScrollVertically && Math.abs(dx) > Math.abs(dy)) { - recyclerView.stopScroll(); - } - } - } - } - } - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/utils/SingletonHolder.kt b/app/src/main/java/be/digitalia/fosdem/utils/SingletonHolder.kt new file mode 100644 index 0000000..5bf810f --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/utils/SingletonHolder.kt @@ -0,0 +1,31 @@ +package be.digitalia.fosdem.utils + +/** + * Kotlin Singleton with argument. + * + * @author Christophe Beyls + */ +open class SingletonHolder(creator: (A) -> T) { + private var creator: ((A) -> T)? = creator + @Volatile + private var instance: T? = null + + fun getInstance(arg: A): T { + val i = instance + if (i != null) { + return i + } + + return synchronized(this) { + val i2 = instance + if (i2 != null) { + i2 + } else { + val created = creator!!(arg) + instance = created + creator = null + created + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/utils/StringUtils.java b/app/src/main/java/be/digitalia/fosdem/utils/StringUtils.java deleted file mode 100644 index 8ac73bb..0000000 --- a/app/src/main/java/be/digitalia/fosdem/utils/StringUtils.java +++ /dev/null @@ -1,281 +0,0 @@ -package be.digitalia.fosdem.utils; - -import android.content.res.Resources; -import android.text.Editable; -import android.text.Html; -import android.text.Spanned; -import android.text.TextUtils; -import android.text.style.BulletSpan; -import android.text.style.LeadingMarginSpan; -import androidx.annotation.NonNull; -import androidx.collection.CircularIntArray; -import androidx.core.text.HtmlCompat; -import org.xml.sax.XMLReader; - -import java.util.Iterator; -import java.util.Locale; - -/** - * Various methods to transform strings - * - * @author Christophe Beyls - */ -public class StringUtils { - /** - * Mirror of the unicode table from 00c0 to 017f without diacritics. - */ - private static final String tab00c0 = "AAAAAAACEEEEIIII" + "DNOOOOO\u00d7\u00d8UUUUYI\u00df" + "aaaaaaaceeeeiiii" + "\u00f0nooooo\u00f7\u00f8uuuuy\u00fey" - + "AaAaAaCcCcCcCcDd" + "DdEeEeEeEeEeGgGg" + "GgGgHhHhIiIiIiIi" + "IiJjJjKkkLlLlLlL" + "lLlNnNnNnnNnOoOo" + "OoOoRrRrRrSsSsSs" + "SsTtTtTtUuUuUuUu" - + "UuUuWwYyYZzZzZzF"; - - private static final String ROOM_DRAWABLE_PREFIX = "room_"; - - /** - * Returns string without diacritics - 7 bit approximation. - * - * @param source string to convert - * @return corresponding string without diacritics - */ - public static String removeDiacritics(@NonNull String source) { - final int length = source.length(); - char[] result = new char[length]; - char c; - for (int i = 0; i < length; i++) { - c = source.charAt(i); - if (c >= '\u00c0' && c <= '\u017f') { - c = tab00c0.charAt((int) c - '\u00c0'); - } - result[i] = c; - } - return new String(result); - } - - public static String remove(String str, final char remove) { - if (TextUtils.isEmpty(str) || str.indexOf(remove) == -1) { - return str; - } - final char[] chars = str.toCharArray(); - int pos = 0; - for (int i = 0; i < chars.length; i++) { - if (chars[i] != remove) { - chars[pos++] = chars[i]; - } - } - return new String(chars, 0, pos); - } - - /** - * Replaces all groups of removable chars in source with a single replacement char. - */ - private static String replaceNonAlphaGroups(String source, char replacement) { - final int length = source.length(); - char[] result = new char[length]; - char c; - boolean replaced = false; - int size = 0; - for (int i = 0; i < length; i++) { - c = source.charAt(i); - if (isRemovableChar(c)) { - // Skip quote - if ((c != '’') && !replaced) { - result[size++] = replacement; - replaced = true; - } - } else { - result[size++] = c; - replaced = false; - } - } - return new String(result, 0, size); - } - - /** - * Removes all removable chars at the beginning and end of source. - */ - private static String trimNonAlpha(String source) { - int st = 0; - int len = source.length(); - - while ((st < len) && isRemovableChar(source.charAt(st))) { - st++; - } - while ((st < len) && isRemovableChar(source.charAt(len - 1))) { - len--; - } - return ((st > 0) || (len < source.length())) ? source.substring(st, len) : source; - } - - private static boolean isRemovableChar(char c) { - return !Character.isLetterOrDigit(c) && c != '_' && c != '@'; - } - - /** - * Transforms a name to a slug identifier to be used in a FOSDEM URL. - */ - public static String toSlug(@NonNull String source) { - source = remove(source, '.'); - source = removeDiacritics(source); - source = source.replace("ß", "ss"); - source = trimNonAlpha(source); - source = replaceNonAlphaGroups(source, '_'); - source = source.toLowerCase(Locale.US); - return source; - } - - public static String stripHtml(@NonNull String html) { - return trimEnd(HtmlCompat.fromHtml(html, HtmlCompat.FROM_HTML_SEPARATOR_LINE_BREAK_LIST_ITEM)).toString(); - } - - public static CharSequence parseHtml(@NonNull String html, Resources res) { - return trimEnd(HtmlCompat.fromHtml(html, HtmlCompat.FROM_HTML_SEPARATOR_LINE_BREAK_LIST_ITEM, null, new ListsTagHandler(res))); - } - - public static CharSequence trimEnd(@NonNull CharSequence source) { - int pos = source.length() - 1; - while ((pos >= 0) && Character.isWhitespace(source.charAt(pos))) { - pos--; - } - pos++; - return (pos < source.length()) ? source.subSequence(0, pos) : source; - } - - /** - * Converts a room name to a local drawable resource name, by stripping non-alpha chars and converting to lower case. Any letter following a digit will be - * ignored, along with the rest of the string. - */ - public static String roomNameToResourceName(@NonNull String roomName) { - StringBuilder builder = new StringBuilder(ROOM_DRAWABLE_PREFIX.length() + roomName.length()); - builder.append(ROOM_DRAWABLE_PREFIX); - int size = roomName.length(); - boolean lastDigit = false; - for (int i = 0; i < size; ++i) { - char c = roomName.charAt(i); - if (Character.isLetter(c)) { - if (lastDigit) { - break; - } - builder.append(Character.toLowerCase(c)); - } else if (Character.isDigit(c)) { - builder.append(c); - lastDigit = true; - } - } - return builder.toString(); - } - - static class ListsTagHandler implements Html.TagHandler { - - private static final float LEADING_MARGIN_DIPS = 2f; - private static final float BULLET_GAP_WIDTH_DIPS = 8f; - - private final CircularIntArray liStarts = new CircularIntArray(4); - private final int leadingMargin; - private final int bulletGapWidth; - - public ListsTagHandler(Resources res) { - final float density = res.getDisplayMetrics().density; - leadingMargin = (int) (density * LEADING_MARGIN_DIPS + 0.5f); - bulletGapWidth = (int) (density * BULLET_GAP_WIDTH_DIPS + 0.5f); - } - - /** - * @return final output length - */ - private static int ensureParagraphBoundary(Editable output) { - int length = output.length(); - if ((length != 0) && output.charAt(length - 1) != '\n') { - output.insert(length, "\n"); - length++; - } - return length; - } - - private static void trimStart(Editable output, final int start) { - int end = start; - final int length = output.length(); - while ((end < length) && Character.isWhitespace(output.charAt(end))) { - end++; - } - if (start < end) { - output.delete(start, end); - } - } - - @Override - public void handleTag(boolean opening, String tag, Editable output, XMLReader xmlReader) { - switch (tag) { - case "pre": - case "PRE": - ensureParagraphBoundary(output); - break; - // Unfortunately the following code will be ignored in API 24+ and the native rendering is inferior - case "li": - case "LI": - if (opening) { - liStarts.addLast(ensureParagraphBoundary(output)); - } else if (!liStarts.isEmpty()) { - int start = liStarts.popLast(); - trimStart(output, start); - int end = ensureParagraphBoundary(output); - // Add leading margin to ensure the bullet is not cut off - output.setSpan(new LeadingMarginSpan.Standard(leadingMargin), start, end, Spanned.SPAN_INCLUSIVE_EXCLUSIVE); - output.setSpan(new BulletSpan(bulletGapWidth), start, end, Spanned.SPAN_INCLUSIVE_EXCLUSIVE); - } - break; - } - } - } - - /** - * A version of Android's SimpleStringSplitter using a String as delimiter. - */ - public static class SimpleStringSplitter implements TextUtils.StringSplitter, Iterator { - private final String mDelimiter; - private String mString; - private int mPosition; - private int mLength; - - /** - * Initializes the splitter. setString may be called later. - * - * @param delimiter the delimiter on which to split - */ - public SimpleStringSplitter(String delimiter) { - mDelimiter = delimiter; - } - - /** - * Sets the string to split - * - * @param string the string to split - */ - public void setString(String string) { - mString = string; - mPosition = 0; - mLength = mString.length(); - } - - @NonNull - public Iterator iterator() { - return this; - } - - public boolean hasNext() { - return mPosition < mLength; - } - - public String next() { - int end = mString.indexOf(mDelimiter, mPosition); - if (end == -1) { - end = mLength; - } - String nextString = mString.substring(mPosition, end); - mPosition = end + mDelimiter.length(); // Skip the delimiter. - return nextString; - } - - public void remove() { - throw new UnsupportedOperationException(); - } - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/utils/StringUtils.kt b/app/src/main/java/be/digitalia/fosdem/utils/StringUtils.kt new file mode 100644 index 0000000..c87199f --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/utils/StringUtils.kt @@ -0,0 +1,188 @@ +package be.digitalia.fosdem.utils + +import android.content.res.Resources +import android.text.Editable +import android.text.Html.TagHandler +import android.text.style.BulletSpan +import android.text.style.LeadingMarginSpan +import androidx.collection.CircularIntArray +import androidx.core.text.HtmlCompat +import androidx.core.text.parseAsHtml +import androidx.core.text.set +import org.xml.sax.XMLReader +import java.util.* + +/** + * Various methods to transform strings + * + * @author Christophe Beyls + */ + +/** + * Mirror of the unicode table from 00c0 to 017f without diacritics. + */ +private const val tab00c0 = "AAAAAAACEEEEIIII" + "DNOOOOO\u00d7\u00d8UUUUYI\u00df" + + "aaaaaaaceeeeiiii" + "\u00f0nooooo\u00f7\u00f8uuuuy\u00fey" + + "AaAaAaCcCcCcCcDd" + "DdEeEeEeEeEeGgGg" + + "GgGgHhHhIiIiIiIi" + "IiJjJjKkkLlLlLlL" + + "lLlNnNnNnnNnOoOo" + "OoOoRrRrRrSsSsSs" + + "SsTtTtTtUuUuUuUu" + "UuUuWwYyYZzZzZzF" +private const val ROOM_DRAWABLE_PREFIX = "room_" + +/** + * Returns string without diacritics - 7 bit approximation. + * + * @return corresponding string without diacritics + */ +fun String.removeDiacritics(): String { + val result = CharArray(length) { i -> + var c = this[i] + if (c in '\u00c0'..'\u017f') { + c = tab00c0[c.toInt() - '\u00c0'.toInt()] + } + c + } + return String(result) +} + +fun String.remove(remove: Char): String { + return if (remove !in this) { + this + } else { + filterNot { it == remove } + } +} + +/** + * Replaces all groups of removable chars in source with a single replacement char. + */ +private fun String.replaceNonAlphaGroups(replacement: Char): String { + val result = CharArray(length) + var replaced = false + var size = 0 + for (c in this) { + if (c.isRemovable) { + // Skip quote + if (c != '’' && !replaced) { + result[size++] = replacement + replaced = true + } + } else { + result[size++] = c + replaced = false + } + } + return String(result, 0, size) +} + +/** + * Removes all removable chars at the beginning and end of source. + */ +private fun String.trimNonAlpha(): String { + return trim { it.isRemovable } +} + +private val Char.isRemovable: Boolean + get() { + return !isLetterOrDigit() && this != '_' && this != '@' + } + +/** + * Transforms a name to a slug identifier to be used in a FOSDEM URL. + */ +fun String.toSlug(): String { + return remove('.') + .removeDiacritics() + .replace("ß", "ss") + .trimNonAlpha() + .replaceNonAlphaGroups('_') + .toLowerCase(Locale.US) +} + +fun String.stripHtml(): String { + return parseAsHtml(flags = HtmlCompat.FROM_HTML_SEPARATOR_LINE_BREAK_LIST_ITEM).trimEnd().toString() +} + +fun String.parseHtml(res: Resources): CharSequence { + return parseAsHtml(flags = HtmlCompat.FROM_HTML_SEPARATOR_LINE_BREAK_LIST_ITEM, tagHandler = ListsTagHandler(res)).trimEnd() +} + +/** + * Converts a room name to a local drawable resource name, by stripping non-alpha chars and converting to lower case. Any letter following a digit will be + * ignored, along with the rest of the string. + */ +fun roomNameToResourceName(roomName: String): String { + val builder = StringBuilder(ROOM_DRAWABLE_PREFIX.length + roomName.length) + builder.append(ROOM_DRAWABLE_PREFIX) + var lastDigit = false + for (c in roomName) { + if (c.isLetter()) { + if (lastDigit) { + break + } + builder.append(c.toLowerCase()) + } else if (c.isDigit()) { + builder.append(c) + lastDigit = true + } + } + return builder.toString() +} + +private class ListsTagHandler(res: Resources) : TagHandler { + + private val liStarts = CircularIntArray(4) + private val leadingMargin: Int + private val bulletGapWidth: Int + + init { + val density = res.displayMetrics.density + leadingMargin = (density * LEADING_MARGIN_DIPS + 0.5f).toInt() + bulletGapWidth = (density * BULLET_GAP_WIDTH_DIPS + 0.5f).toInt() + } + + /** + * @return final output length + */ + private fun ensureParagraphBoundary(output: Editable): Int { + var length = output.length + if (length != 0 && output[length - 1] != '\n') { + output.insert(length, "\n") + length++ + } + return length + } + + private fun trimStart(output: Editable, start: Int) { + var end = start + val length = output.length + while (end < length && output[end].isWhitespace()) { + end++ + } + if (start < end) { + output.delete(start, end) + } + } + + override fun handleTag(opening: Boolean, tag: String, output: Editable, xmlReader: XMLReader) { + when (tag) { + "pre", "PRE" -> ensureParagraphBoundary(output) + // Unfortunately the following code will be ignored in API 24+ and the native rendering is inferior + "li", "LI" -> if (opening) { + liStarts.addLast(ensureParagraphBoundary(output)) + } else if (!liStarts.isEmpty) { + val start = liStarts.popLast() + trimStart(output, start) + val end = ensureParagraphBoundary(output) + // Add leading margin to ensure the bullet is not cut off + output[start, end] = LeadingMarginSpan.Standard(leadingMargin) + output[start, end] = BulletSpan(bulletGapWidth) + } + } + } + + companion object { + private const val LEADING_MARGIN_DIPS = 2f + private const val BULLET_GAP_WIDTH_DIPS = 8f + } +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/utils/ThemeManager.java b/app/src/main/java/be/digitalia/fosdem/utils/ThemeManager.java deleted file mode 100644 index 15ad78f..0000000 --- a/app/src/main/java/be/digitalia/fosdem/utils/ThemeManager.java +++ /dev/null @@ -1,43 +0,0 @@ -package be.digitalia.fosdem.utils; - -import android.content.Context; -import android.content.SharedPreferences; - -import androidx.annotation.NonNull; -import androidx.appcompat.app.AppCompatDelegate; -import androidx.preference.PreferenceManager; - -import be.digitalia.fosdem.fragments.SettingsFragment; - -public class ThemeManager implements SharedPreferences.OnSharedPreferenceChangeListener { - - private static ThemeManager instance; - - private ThemeManager(@NonNull Context context) { - final SharedPreferences defaultSharedPreferences = PreferenceManager.getDefaultSharedPreferences(context.getApplicationContext()); - updateTheme(defaultSharedPreferences); - defaultSharedPreferences.registerOnSharedPreferenceChangeListener(this); - } - - public static void init(Context context) { - if (instance == null) { - instance = new ThemeManager(context); - } - } - - public static ThemeManager getInstance() { - return instance; - } - - @Override - public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) { - if (SettingsFragment.KEY_PREF_THEME.equals(key)) { - updateTheme(sharedPreferences); - } - } - - private void updateTheme(SharedPreferences sharedPreferences) { - final String stringMode = sharedPreferences.getString(SettingsFragment.KEY_PREF_THEME, String.valueOf(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)); - AppCompatDelegate.setDefaultNightMode(Integer.parseInt(stringMode)); - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/utils/ThemeManager.kt b/app/src/main/java/be/digitalia/fosdem/utils/ThemeManager.kt new file mode 100644 index 0000000..98bb04f --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/utils/ThemeManager.kt @@ -0,0 +1,27 @@ +package be.digitalia.fosdem.utils + +import android.content.Context +import android.content.SharedPreferences +import android.content.SharedPreferences.OnSharedPreferenceChangeListener +import androidx.appcompat.app.AppCompatDelegate +import androidx.preference.PreferenceManager + +object ThemeManager { + + private val onSharedPreferenceChangeListener = OnSharedPreferenceChangeListener { sharedPreferences, key -> + if (key == PreferenceKeys.THEME) { + updateTheme(sharedPreferences) + } + } + + fun init(context: Context) { + val defaultSharedPreferences = PreferenceManager.getDefaultSharedPreferences(context.applicationContext) + updateTheme(defaultSharedPreferences) + defaultSharedPreferences.registerOnSharedPreferenceChangeListener(onSharedPreferenceChangeListener) + } + + private fun updateTheme(sharedPreferences: SharedPreferences) { + val mode = sharedPreferences.getString(PreferenceKeys.THEME, AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM.toString())!!.toInt() + AppCompatDelegate.setDefaultNightMode(mode) + } +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/utils/ThemeUtils.java b/app/src/main/java/be/digitalia/fosdem/utils/ThemeUtils.java deleted file mode 100644 index 5b79868..0000000 --- a/app/src/main/java/be/digitalia/fosdem/utils/ThemeUtils.java +++ /dev/null @@ -1,60 +0,0 @@ -package be.digitalia.fosdem.utils; - -import android.app.Activity; -import android.app.ActivityManager; -import android.content.Context; -import android.content.res.ColorStateList; -import android.graphics.ColorFilter; -import android.graphics.ColorMatrixColorFilter; -import android.graphics.drawable.Drawable; -import android.os.Build; -import android.util.TypedValue; -import android.view.View; -import android.widget.ImageView; - -import androidx.annotation.ColorInt; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.graphics.drawable.DrawableCompat; -import be.digitalia.fosdem.R; - -public class ThemeUtils { - - @SuppressWarnings("deprecation") - public static void setActivityColors(@NonNull Activity activity, @ColorInt int taskColor, @ColorInt int statusBarColor) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - activity.getWindow().setStatusBarColor(statusBarColor); - final ActivityManager.TaskDescription taskDescription; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - taskDescription = new ActivityManager.TaskDescription(null, 0, taskColor | 0xFF000000); - } else { - taskDescription = new ActivityManager.TaskDescription(null, null, taskColor | 0xFF000000); - } - activity.setTaskDescription(taskDescription); - } - } - - public static void tintBackground(@NonNull View view, @Nullable ColorStateList backgroundColor) { - final Drawable background = view.getBackground(); - if (background != null) { - background.mutate(); - DrawableCompat.setTintList(background, backgroundColor); - } - } - - public static boolean isLightTheme(@NonNull Context context) { - final TypedValue value = new TypedValue(); - return context.getTheme().resolveAttribute(R.attr.isLightTheme, value, true) - && value.data != 0; - } - - public static void invertImageColors(@NonNull ImageView imageView) { - final ColorFilter invertColorFilter = new ColorMatrixColorFilter(new float[]{ - -1.0f, 0.0f, 0.0f, 0.0f, 255.0f, - 0.0f, -1.0f, 0.0f, 0.0f, 255.0f, - 0.0f, 0.0f, -1.0f, 0.0f, 255.0f, - 0.0f, 0.0f, 0.0f, 1.0f, 0.0f - }); - imageView.setColorFilter(invertColorFilter); - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/utils/ThemeUtils.kt b/app/src/main/java/be/digitalia/fosdem/utils/ThemeUtils.kt new file mode 100644 index 0000000..06bb8b7 --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/utils/ThemeUtils.kt @@ -0,0 +1,63 @@ +package be.digitalia.fosdem.utils + +import android.app.Activity +import android.app.ActivityManager.TaskDescription +import android.content.Context +import android.content.res.ColorStateList +import android.graphics.Color +import android.graphics.ColorFilter +import android.graphics.ColorMatrixColorFilter +import android.os.Build +import android.util.TypedValue +import android.view.View +import android.view.Window +import android.widget.ImageView +import androidx.annotation.ColorInt +import androidx.core.graphics.drawable.DrawableCompat +import be.digitalia.fosdem.R + +var Window.statusBarColorCompat: Int + @ColorInt + get() { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) statusBarColor else Color.BLACK + } + set(@ColorInt color) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + statusBarColor = color + } + } + +@Suppress("DEPRECATION") +fun Activity.setTaskColorPrimary(@ColorInt colorPrimary: Int) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + val taskDescription = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + TaskDescription(null, 0, colorPrimary or -0x1000000) + } else { + TaskDescription(null, null, colorPrimary or -0x1000000) + } + setTaskDescription(taskDescription) + } +} + +fun View.tintBackground(backgroundColor: ColorStateList?) { + background?.let { + it.mutate() + DrawableCompat.setTintList(it, backgroundColor) + } +} + +val Context.isLightTheme: Boolean + get() { + val value = TypedValue() + return theme.resolveAttribute(R.attr.isLightTheme, value, true) && value.data != 0 + } + +fun ImageView.invertImageColors() { + val invertColorFilter: ColorFilter = ColorMatrixColorFilter(floatArrayOf( + -1.0f, 0.0f, 0.0f, 0.0f, 255.0f, + 0.0f, -1.0f, 0.0f, 0.0f, 255.0f, + 0.0f, 0.0f, -1.0f, 0.0f, 255.0f, + 0.0f, 0.0f, 0.0f, 1.0f, 0.0f + )) + colorFilter = invertColorFilter +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/utils/XmlPullParserExt.kt b/app/src/main/java/be/digitalia/fosdem/utils/XmlPullParserExt.kt new file mode 100644 index 0000000..f2a1d78 --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/utils/XmlPullParserExt.kt @@ -0,0 +1,45 @@ +package be.digitalia.fosdem.utils + +import org.xmlpull.v1.XmlPullParser +import org.xmlpull.v1.XmlPullParserFactory + +val xmlPullParserFactory: XmlPullParserFactory by lazy { + XmlPullParserFactory.newInstance() +} + +/* + * Checks if the current event is the end of the document + */ +inline val XmlPullParser.isEndDocument + get() = eventType == XmlPullParser.END_DOCUMENT + +/* + * Checks if the current event is a start tag + */ +inline val XmlPullParser.isStartTag + get() = eventType == XmlPullParser.START_TAG + +/* + * Checks if the current event is a start tag with the specified local name + */ +@Suppress("NOTHING_TO_INLINE") +inline fun XmlPullParser.isStartTag(name: String) = eventType == XmlPullParser.START_TAG && name == this.name + +/* + * Go to the next event and check if the current event is an end tag with the specified local name + */ +@Suppress("NOTHING_TO_INLINE") +inline fun XmlPullParser.isNextEndTag(name: String) = next() == XmlPullParser.END_TAG && name == this.name + +/* + * Skips the start tag and positions the reader on the corresponding end tag + */ +fun XmlPullParser.skipToEndTag() { + var type = next() + while (type != XmlPullParser.END_TAG) { + if (type == XmlPullParser.START_TAG) { + skipToEndTag() + } + type = next() + } +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/utils/network/HttpUtils.java b/app/src/main/java/be/digitalia/fosdem/utils/network/HttpUtils.java deleted file mode 100644 index 6c636a3..0000000 --- a/app/src/main/java/be/digitalia/fosdem/utils/network/HttpUtils.java +++ /dev/null @@ -1,132 +0,0 @@ -package be.digitalia.fosdem.utils.network; - -import android.os.Build; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import java.io.IOException; -import java.net.HttpURLConnection; -import java.net.URL; -import java.security.KeyStore; -import java.util.Arrays; -import java.util.concurrent.TimeUnit; - -import javax.net.ssl.SSLContext; -import javax.net.ssl.TrustManager; -import javax.net.ssl.TrustManagerFactory; -import javax.net.ssl.X509TrustManager; - -import be.digitalia.fosdem.utils.ByteCountSource; -import okhttp3.OkHttpClient; -import okhttp3.Request; -import okhttp3.ResponseBody; -import okio.BufferedSource; -import okio.Okio; - -/** - * Utility class to perform HTTP requests. - * - * @author Christophe Beyls - */ -public class HttpUtils { - - private static final long DEFAULT_CONNECT_TIMEOUT = 10L; - private static final long DEFAULT_READ_TIMEOUT = 10L; - - private static OkHttpClient sClient = enableTls12(new OkHttpClient.Builder()) - .connectTimeout(DEFAULT_CONNECT_TIMEOUT, TimeUnit.SECONDS) - .readTimeout(DEFAULT_READ_TIMEOUT, TimeUnit.SECONDS) - .build(); - - private HttpUtils() { - } - - public static class Response { - // Will be null when the local content is up-to-date - @Nullable - public BufferedSource source; - @Nullable - public String lastModified; - } - - public interface ProgressUpdateListener { - void onProgressUpdate(int percent); - } - - public static BufferedSource get(@NonNull String path) throws IOException { - return get(new URL(path), null, null).source; - } - - public static Response get(@NonNull String path, @Nullable String lastModified, @Nullable ProgressUpdateListener listener) - throws IOException { - return get(new URL(path), lastModified, listener); - } - - public static Response get(@NonNull URL url, @Nullable String lastModified, @Nullable final ProgressUpdateListener listener) - throws IOException { - Request.Builder requestBuilder = new Request.Builder(); - - if (lastModified != null) { - requestBuilder.header("If-Modified-Since", lastModified); - } - - Request request = requestBuilder - .url(url) - .build(); - - final Response response = new Response(); - final okhttp3.Response okhttpResponse = sClient.newCall(request).execute(); - final ResponseBody body = okhttpResponse.body(); - if (!okhttpResponse.isSuccessful() || (body == null)) { - if ((okhttpResponse.code() == HttpURLConnection.HTTP_NOT_MODIFIED) && (lastModified != null)) { - // Cached result is still valid; return an empty response - return response; - } - - if (body != null) { - body.close(); - } - throw new IOException("Server returned response code: " + okhttpResponse.code()); - } - - response.lastModified = okhttpResponse.header("Last-Modified"); - - final long length = body.contentLength(); - if ((listener != null) && (length != -1L)) { - // Broadcast the progression in percents, with a precision of 1/10 of the total file size - response.source = Okio.buffer(new ByteCountSource(body.source(), - byteCount -> { - // Cap percent to 100 - int percent = (byteCount >= length) ? 100 : (int) (byteCount * 100L / length); - listener.onProgressUpdate(percent); - }, length / 10L)); - } else { - response.source = body.source(); - } - - return response; - } - - private static OkHttpClient.Builder enableTls12(OkHttpClient.Builder builder) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP_MR1) { - try { - final TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); - trustManagerFactory.init((KeyStore) null); - final TrustManager[] trustManagers = trustManagerFactory.getTrustManagers(); - if (trustManagers.length != 1 || !(trustManagers[0] instanceof X509TrustManager)) { - throw new IllegalStateException("Unexpected default trust managers: " + Arrays.toString(trustManagers)); - } - final X509TrustManager trustManager = (X509TrustManager) trustManagers[0]; - - final SSLContext sslContext = SSLContext.getInstance("TLS"); - sslContext.init(null, new TrustManager[]{trustManager}, null); - - builder.sslSocketFactory(new Tls12SocketFactory(sslContext.getSocketFactory()), trustManager); - } catch (Exception e) { - e.printStackTrace(); - } - } - return builder; - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/utils/network/HttpUtils.kt b/app/src/main/java/be/digitalia/fosdem/utils/network/HttpUtils.kt new file mode 100644 index 0000000..bcb10c0 --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/utils/network/HttpUtils.kt @@ -0,0 +1,126 @@ +package be.digitalia.fosdem.utils.network + +import android.os.Build +import be.digitalia.fosdem.utils.ByteCountSource +import be.digitalia.fosdem.utils.ByteCountSource.ByteCountListener +import okhttp3.OkHttpClient +import okhttp3.Request +import okio.BufferedSource +import okio.buffer +import java.io.IOException +import java.net.HttpURLConnection +import java.net.URL +import java.security.KeyStore +import java.util.* +import java.util.concurrent.TimeUnit +import javax.net.ssl.SSLContext +import javax.net.ssl.TrustManager +import javax.net.ssl.TrustManagerFactory +import javax.net.ssl.X509TrustManager + +/** + * Utility class to perform HTTP requests. + * + * @author Christophe Beyls + */ +object HttpUtils { + + private const val DEFAULT_CONNECT_TIMEOUT = 10L + private const val DEFAULT_READ_TIMEOUT = 10L + + private val client = OkHttpClient.Builder() + .enableTls12() + .connectTimeout(DEFAULT_CONNECT_TIMEOUT, TimeUnit.SECONDS) + .readTimeout(DEFAULT_READ_TIMEOUT, TimeUnit.SECONDS) + .build() + + @Throws(IOException::class) + fun get(path: String): BufferedSource { + return when (val response = get(URL(path))) { + // Can only receive NotModified if lastModified argument is non-null + is Response.NotModified -> throw IllegalStateException() + is Response.Success -> response.source + } + } + + /** + * @param progressListener optional listener for the download progress in percents (0..100) + */ + @Throws(IOException::class) + fun get(path: String, lastModified: String? = null, progressListener: ((percent: Int) -> Unit)? = null): Response { + return get(URL(path), lastModified, progressListener) + } + + /** + * @param progressListener optional listener for the download progress in percents (0..100) + */ + @Throws(IOException::class) + fun get(url: URL, lastModified: String? = null, progressListener: ((percent: Int) -> Unit)? = null): Response { + val requestBuilder = Request.Builder() + if (lastModified != null) { + requestBuilder.header("If-Modified-Since", lastModified) + } + + val request = requestBuilder + .url(url) + .build() + + val okhttpResponse = client.newCall(request).execute() + val body = okhttpResponse.body() + if (!okhttpResponse.isSuccessful || body == null) { + if (okhttpResponse.code() == HttpURLConnection.HTTP_NOT_MODIFIED && lastModified != null) { + // Cached result is still valid; return an empty response + return Response.NotModified + } + + body?.close() + throw IOException("Server returned response code: " + okhttpResponse.code()) + } + + val responseLastModified = okhttpResponse.header("Last-Modified") + + val length = body.contentLength() + val source = if (progressListener != null && length != -1L) { + // Broadcast the progression in percents, with a precision of 1/10 of the total file size + val byteCountListener = object : ByteCountListener { + override fun onNewCount(byteCount: Long) { + // Cap percent to 100 + val percent = if (byteCount >= length) 100 else (byteCount * 100L / length).toInt() + progressListener(percent) + } + } + ByteCountSource(body.source(), byteCountListener, length / 10L).buffer() + } else { + body.source() + } + + return Response.Success(source, responseLastModified) + } + + private fun OkHttpClient.Builder.enableTls12(): OkHttpClient.Builder { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP_MR1) { + try { + val trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) + trustManagerFactory.init(null as KeyStore?) + val trustManagers = trustManagerFactory.trustManagers + check(!(trustManagers.size != 1 || trustManagers[0] !is X509TrustManager)) { + "Unexpected default trust managers: " + Arrays.toString(trustManagers) + } + val trustManager = trustManagers[0] as X509TrustManager + + val sslContext = SSLContext.getInstance("TLS") + sslContext.init(null, arrayOf(trustManager), null) + + sslSocketFactory(Tls12SocketFactory(sslContext.socketFactory), trustManager) + } catch (e: Exception) { + e.printStackTrace() + } + } + return this + } + + sealed class Response { + object NotModified : Response() + class Success(val source: BufferedSource, val lastModified: String? = null) : Response() + } +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/utils/network/Tls12SocketFactory.java b/app/src/main/java/be/digitalia/fosdem/utils/network/Tls12SocketFactory.java deleted file mode 100644 index 19c5c02..0000000 --- a/app/src/main/java/be/digitalia/fosdem/utils/network/Tls12SocketFactory.java +++ /dev/null @@ -1,69 +0,0 @@ -package be.digitalia.fosdem.utils.network; - -import java.io.IOException; -import java.net.InetAddress; -import java.net.Socket; - -import javax.net.ssl.SSLSocket; -import javax.net.ssl.SSLSocketFactory; - -/** - * Enables TLS v1.2 when creating SSLSockets. - *

- * For some reason, android supports TLS v1.2 from API 16, but enables it by - * default only from API 20. - * - * @link https://developer.android.com/reference/javax/net/ssl/SSLSocket.html - * @see SSLSocketFactory - */ -public class Tls12SocketFactory extends SSLSocketFactory { - private static final String[] TLS_PROTOCOLS = {"TLSv1.1", "TLSv1.2"}; - - private final SSLSocketFactory delegate; - - public Tls12SocketFactory(SSLSocketFactory base) { - this.delegate = base; - } - - @Override - public String[] getDefaultCipherSuites() { - return delegate.getDefaultCipherSuites(); - } - - @Override - public String[] getSupportedCipherSuites() { - return delegate.getSupportedCipherSuites(); - } - - @Override - public Socket createSocket(Socket s, String host, int port, boolean autoClose) throws IOException { - return patch(delegate.createSocket(s, host, port, autoClose)); - } - - @Override - public Socket createSocket(String host, int port) throws IOException { - return patch(delegate.createSocket(host, port)); - } - - @Override - public Socket createSocket(String host, int port, InetAddress localHost, int localPort) throws IOException { - return patch(delegate.createSocket(host, port, localHost, localPort)); - } - - @Override - public Socket createSocket(InetAddress host, int port) throws IOException { - return patch(delegate.createSocket(host, port)); - } - - @Override - public Socket createSocket(InetAddress address, int port, InetAddress localAddress, int localPort) throws IOException { - return patch(delegate.createSocket(address, port, localAddress, localPort)); - } - - private Socket patch(Socket s) { - if (s instanceof SSLSocket) { - ((SSLSocket) s).setEnabledProtocols(TLS_PROTOCOLS); - } - return s; - } -} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/utils/network/Tls12SocketFactory.kt b/app/src/main/java/be/digitalia/fosdem/utils/network/Tls12SocketFactory.kt new file mode 100644 index 0000000..97a9705 --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/utils/network/Tls12SocketFactory.kt @@ -0,0 +1,54 @@ +package be.digitalia.fosdem.utils.network + +import java.net.InetAddress +import java.net.Socket +import javax.net.ssl.SSLSocket +import javax.net.ssl.SSLSocketFactory + +/** + * Enables TLS v1.2 when creating SSLSockets. + * + * + * For some reason, android supports TLS v1.2 from API 16, but enables it by + * default only from API 20. + * + * @link https://developer.android.com/reference/javax/net/ssl/SSLSocket.html + * @see SSLSocketFactory + */ +class Tls12SocketFactory(private val delegate: SSLSocketFactory) : SSLSocketFactory() { + + override fun getDefaultCipherSuites(): Array = delegate.defaultCipherSuites + + override fun getSupportedCipherSuites(): Array = delegate.supportedCipherSuites + + override fun createSocket(s: Socket, host: String, port: Int, autoClose: Boolean): Socket { + return patch(delegate.createSocket(s, host, port, autoClose)) + } + + override fun createSocket(host: String, port: Int): Socket { + return patch(delegate.createSocket(host, port)) + } + + override fun createSocket(host: String, port: Int, localHost: InetAddress, localPort: Int): Socket { + return patch(delegate.createSocket(host, port, localHost, localPort)) + } + + override fun createSocket(host: InetAddress, port: Int): Socket { + return patch(delegate.createSocket(host, port)) + } + + override fun createSocket(address: InetAddress, port: Int, localAddress: InetAddress, localPort: Int): Socket { + return patch(delegate.createSocket(address, port, localAddress, localPort)) + } + + private fun patch(s: Socket): Socket { + if (s is SSLSocket) { + s.enabledProtocols = TLS_PROTOCOLS + } + return s + } + + companion object { + private val TLS_PROTOCOLS = arrayOf("TLSv1.1", "TLSv1.2") + } +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/viewmodels/BookmarkStatusViewModel.java b/app/src/main/java/be/digitalia/fosdem/viewmodels/BookmarkStatusViewModel.java deleted file mode 100644 index 7e843c6..0000000 --- a/app/src/main/java/be/digitalia/fosdem/viewmodels/BookmarkStatusViewModel.java +++ /dev/null @@ -1,82 +0,0 @@ -package be.digitalia.fosdem.viewmodels; - -import android.app.Application; -import android.os.AsyncTask; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.arch.core.util.Function; -import androidx.core.util.ObjectsCompat; -import androidx.lifecycle.AndroidViewModel; -import androidx.lifecycle.LiveData; -import androidx.lifecycle.MutableLiveData; -import androidx.lifecycle.Transformations; -import be.digitalia.fosdem.db.AppDatabase; -import be.digitalia.fosdem.model.BookmarkStatus; -import be.digitalia.fosdem.model.Event; - -public class BookmarkStatusViewModel extends AndroidViewModel { - - private final AppDatabase appDatabase = AppDatabase.getInstance(getApplication()); - private final MutableLiveData event = new MutableLiveData<>(); - private final LiveData bookmarkStatus = Transformations.switchMap(event, - new Function>() { - @Override - public LiveData apply(Event event) { - if (event == null) { - MutableLiveData singleNullResult = new MutableLiveData<>(); - singleNullResult.setValue(null); - return singleNullResult; - } - - return Transformations.map( - // Prevent updating the UI when a bookmark is added back or removed back - Transformations.distinctUntilChanged( - appDatabase.getBookmarksDao().getBookmarkStatus(event) - ), isBookmarked -> { - if (isBookmarked == null) { - return null; - } - final boolean isUpdate = firstResultReceived; - firstResultReceived = true; - return new BookmarkStatus(isBookmarked, isUpdate); - } - ); - } - }); - private boolean firstResultReceived = false; - - public BookmarkStatusViewModel(@NonNull Application application) { - super(application); - } - - public void setEvent(Event event) { - if (!ObjectsCompat.equals(event, this.event.getValue())) { - firstResultReceived = false; - this.event.setValue(event); - } - } - - @Nullable - public Event getEvent() { - return event.getValue(); - } - - public LiveData getBookmarkStatus() { - return bookmarkStatus; - } - - public void toggleBookmarkStatus() { - final Event event = this.event.getValue(); - final BookmarkStatus currentStatus = bookmarkStatus.getValue(); - // Ignore the action if the status for the current event hasn't been received yet - if (event != null && currentStatus != null && firstResultReceived) { - AsyncTask.SERIAL_EXECUTOR.execute(() -> { - if (currentStatus.isBookmarked()) { - appDatabase.getBookmarksDao().removeBookmark(event); - } else { - appDatabase.getBookmarksDao().addBookmark(event); - } - }); - } - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/viewmodels/BookmarkStatusViewModel.kt b/app/src/main/java/be/digitalia/fosdem/viewmodels/BookmarkStatusViewModel.kt new file mode 100644 index 0000000..5c23d77 --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/viewmodels/BookmarkStatusViewModel.kt @@ -0,0 +1,50 @@ +package be.digitalia.fosdem.viewmodels + +import android.app.Application +import androidx.lifecycle.* +import be.digitalia.fosdem.db.AppDatabase +import be.digitalia.fosdem.model.BookmarkStatus +import be.digitalia.fosdem.model.Event + +class BookmarkStatusViewModel(application: Application) : AndroidViewModel(application) { + + private val appDatabase = AppDatabase.getInstance(application) + private val eventLiveData = MutableLiveData() + private var firstResultReceived = false + + val bookmarkStatus: LiveData = eventLiveData.switchMap { event -> + if (event == null) { + MutableLiveData(null) + } else { + appDatabase.bookmarksDao.getBookmarkStatus(event) + .distinctUntilChanged() // Prevent updating the UI when a bookmark is added back or removed back + .map { isBookmarked -> + val isUpdate = firstResultReceived + firstResultReceived = true + BookmarkStatus(isBookmarked, isUpdate) + } + } + } + + var event: Event? + get() = eventLiveData.value + set(value) { + if (value != eventLiveData.value) { + firstResultReceived = false + eventLiveData.value = value + } + } + + fun toggleBookmarkStatus() { + val event = event + val currentStatus = bookmarkStatus.value + // Ignore the action if the status for the current event hasn't been received yet + if (event != null && currentStatus != null && firstResultReceived) { + if (currentStatus.isBookmarked) { + appDatabase.bookmarksDao.removeBookmarkAsync(event) + } else { + appDatabase.bookmarksDao.addBookmarkAsync(event) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/viewmodels/BookmarksViewModel.java b/app/src/main/java/be/digitalia/fosdem/viewmodels/BookmarksViewModel.java deleted file mode 100644 index 3e40e70..0000000 --- a/app/src/main/java/be/digitalia/fosdem/viewmodels/BookmarksViewModel.java +++ /dev/null @@ -1,59 +0,0 @@ -package be.digitalia.fosdem.viewmodels; - -import android.app.Application; -import android.os.AsyncTask; -import android.text.format.DateUtils; -import androidx.annotation.NonNull; -import androidx.lifecycle.AndroidViewModel; -import androidx.lifecycle.LiveData; -import androidx.lifecycle.MutableLiveData; -import androidx.lifecycle.Transformations; -import be.digitalia.fosdem.db.AppDatabase; -import be.digitalia.fosdem.livedata.LiveDataFactory; -import be.digitalia.fosdem.model.Event; - -import java.util.List; -import java.util.concurrent.TimeUnit; - -public class BookmarksViewModel extends AndroidViewModel { - - // In upcomingOnly mode, events that just started are still shown for 5 minutes - static final long TIME_OFFSET = 5L * DateUtils.MINUTE_IN_MILLIS; - - private final AppDatabase appDatabase = AppDatabase.getInstance(getApplication()); - private final MutableLiveData upcomingOnly = new MutableLiveData<>(); - private final LiveData> bookmarks = Transformations.switchMap(upcomingOnly, - upcomingOnly -> { - if (upcomingOnly == Boolean.TRUE) { - // Refresh upcoming bookmarks every 2 minutes - final LiveData heartbeat = LiveDataFactory.interval(2L, TimeUnit.MINUTES); - return Transformations.switchMap(heartbeat, - version -> appDatabase.getBookmarksDao().getBookmarks(System.currentTimeMillis() - TIME_OFFSET)); - } - - return appDatabase.getBookmarksDao().getBookmarks(-1L); - }); - - public BookmarksViewModel(@NonNull Application application) { - super(application); - } - - public void setUpcomingOnly(boolean upcomingOnly) { - final Boolean boxedUpcomingOnly = upcomingOnly; - if (!boxedUpcomingOnly.equals(this.upcomingOnly.getValue())) { - this.upcomingOnly.setValue(boxedUpcomingOnly); - } - } - - public boolean getUpcomingOnly() { - return Boolean.TRUE.equals(this.upcomingOnly.getValue()); - } - - public LiveData> getBookmarks() { - return bookmarks; - } - - public void removeBookmarks(final long[] eventIds) { - AsyncTask.SERIAL_EXECUTOR.execute(() -> appDatabase.getBookmarksDao().removeBookmarks(eventIds)); - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/viewmodels/BookmarksViewModel.kt b/app/src/main/java/be/digitalia/fosdem/viewmodels/BookmarksViewModel.kt new file mode 100644 index 0000000..8bf74e5 --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/viewmodels/BookmarksViewModel.kt @@ -0,0 +1,47 @@ +package be.digitalia.fosdem.viewmodels + +import android.app.Application +import android.text.format.DateUtils +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.switchMap +import be.digitalia.fosdem.db.AppDatabase +import be.digitalia.fosdem.livedata.LiveDataFactory +import be.digitalia.fosdem.model.Event +import java.util.concurrent.TimeUnit + +class BookmarksViewModel(application: Application) : AndroidViewModel(application) { + + private val appDatabase = AppDatabase.getInstance(application) + private val upcomingOnlyLiveData = MutableLiveData() + + val bookmarks: LiveData> = upcomingOnlyLiveData.switchMap { upcomingOnly: Boolean -> + if (upcomingOnly) { + // Refresh upcoming bookmarks every 2 minutes + LiveDataFactory.interval(2L, TimeUnit.MINUTES) + .switchMap { + appDatabase.bookmarksDao.getBookmarks(System.currentTimeMillis() - TIME_OFFSET) + } + } else { + appDatabase.bookmarksDao.getBookmarks(-1L) + } + } + + var upcomingOnly: Boolean + get() = upcomingOnlyLiveData.value == true + set(value) { + if (value != upcomingOnlyLiveData.value) { + upcomingOnlyLiveData.value = value + } + } + + fun removeBookmarks(eventIds: LongArray) { + appDatabase.bookmarksDao.removeBookmarksAsync(*eventIds) + } + + companion object { + // In upcomingOnly mode, events that just started are still shown for 5 minutes + private const val TIME_OFFSET = 5L * DateUtils.MINUTE_IN_MILLIS + } +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/viewmodels/EventDetailsViewModel.java b/app/src/main/java/be/digitalia/fosdem/viewmodels/EventDetailsViewModel.java deleted file mode 100644 index f40d5de..0000000 --- a/app/src/main/java/be/digitalia/fosdem/viewmodels/EventDetailsViewModel.java +++ /dev/null @@ -1,33 +0,0 @@ -package be.digitalia.fosdem.viewmodels; - -import android.app.Application; -import androidx.annotation.NonNull; -import androidx.lifecycle.AndroidViewModel; -import androidx.lifecycle.LiveData; -import androidx.lifecycle.MutableLiveData; -import androidx.lifecycle.Transformations; -import be.digitalia.fosdem.db.AppDatabase; -import be.digitalia.fosdem.model.Event; -import be.digitalia.fosdem.model.EventDetails; - -public class EventDetailsViewModel extends AndroidViewModel { - - private final AppDatabase appDatabase = AppDatabase.getInstance(getApplication()); - private final MutableLiveData event = new MutableLiveData<>(); - private final LiveData eventDetails = Transformations.switchMap(event, - event -> appDatabase.getScheduleDao().getEventDetails(event)); - - public EventDetailsViewModel(@NonNull Application application) { - super(application); - } - - public void setEvent(@NonNull Event event) { - if (!event.equals(this.event.getValue())) { - this.event.setValue(event); - } - } - - public LiveData getEventDetails() { - return eventDetails; - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/viewmodels/EventDetailsViewModel.kt b/app/src/main/java/be/digitalia/fosdem/viewmodels/EventDetailsViewModel.kt new file mode 100644 index 0000000..687b21b --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/viewmodels/EventDetailsViewModel.kt @@ -0,0 +1,26 @@ +package be.digitalia.fosdem.viewmodels + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.switchMap +import be.digitalia.fosdem.db.AppDatabase +import be.digitalia.fosdem.model.Event +import be.digitalia.fosdem.model.EventDetails + +class EventDetailsViewModel(application: Application) : AndroidViewModel(application) { + + private val appDatabase = AppDatabase.getInstance(application) + private val eventLiveData = MutableLiveData() + + val eventDetails: LiveData = eventLiveData.switchMap { event: Event -> + appDatabase.scheduleDao.getEventDetails(event) + } + + fun setEvent(event: Event) { + if (event != eventLiveData.value) { + eventLiveData.value = event + } + } +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/viewmodels/EventViewModel.java b/app/src/main/java/be/digitalia/fosdem/viewmodels/EventViewModel.java deleted file mode 100644 index 526da6c..0000000 --- a/app/src/main/java/be/digitalia/fosdem/viewmodels/EventViewModel.java +++ /dev/null @@ -1,44 +0,0 @@ -package be.digitalia.fosdem.viewmodels; - -import android.app.Application; -import androidx.annotation.NonNull; -import androidx.lifecycle.AndroidViewModel; -import androidx.lifecycle.LiveData; -import androidx.lifecycle.MutableLiveData; -import androidx.lifecycle.Transformations; -import be.digitalia.fosdem.db.AppDatabase; -import be.digitalia.fosdem.model.Event; - -public class EventViewModel extends AndroidViewModel { - - private final AppDatabase appDatabase = AppDatabase.getInstance(getApplication()); - private final MutableLiveData eventId = new MutableLiveData<>(); - private final LiveData event = Transformations.switchMap(eventId, - id -> { - final MutableLiveData resultLiveData = new MutableLiveData<>(); - appDatabase.getQueryExecutor().execute(() -> { - final Event result = appDatabase.getScheduleDao().getEvent(id); - resultLiveData.postValue(result); - }); - return resultLiveData; - }); - - public EventViewModel(@NonNull Application application) { - super(application); - } - - public boolean hasEventId() { - return this.eventId.getValue() != null; - } - - public void setEventId(long eventId) { - Long newEventId = eventId; - if (!newEventId.equals(this.eventId.getValue())) { - this.eventId.setValue(newEventId); - } - } - - public LiveData getEvent() { - return event; - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/viewmodels/EventViewModel.kt b/app/src/main/java/be/digitalia/fosdem/viewmodels/EventViewModel.kt new file mode 100644 index 0000000..9e3a805 --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/viewmodels/EventViewModel.kt @@ -0,0 +1,31 @@ +package be.digitalia.fosdem.viewmodels + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.switchMap +import be.digitalia.fosdem.db.AppDatabase +import be.digitalia.fosdem.model.Event + +class EventViewModel(application: Application) : AndroidViewModel(application) { + + private val appDatabase = AppDatabase.getInstance(application) + private val eventIdLiveData = MutableLiveData() + + val event: LiveData = eventIdLiveData.switchMap { id: Long -> + MutableLiveData().also { + appDatabase.queryExecutor.execute { + it.postValue(appDatabase.scheduleDao.getEvent(id)) + } + } + } + + val isEventIdSet = eventIdLiveData.value != null + + fun setEventId(eventId: Long) { + if (eventId != eventIdLiveData.value) { + eventIdLiveData.value = eventId + } + } +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/viewmodels/ExternalBookmarksViewModel.java b/app/src/main/java/be/digitalia/fosdem/viewmodels/ExternalBookmarksViewModel.java deleted file mode 100644 index 9aa3de1..0000000 --- a/app/src/main/java/be/digitalia/fosdem/viewmodels/ExternalBookmarksViewModel.java +++ /dev/null @@ -1,37 +0,0 @@ -package be.digitalia.fosdem.viewmodels; - -import android.app.Application; -import androidx.annotation.NonNull; -import androidx.lifecycle.AndroidViewModel; -import androidx.lifecycle.LiveData; -import androidx.lifecycle.MutableLiveData; -import androidx.lifecycle.Transformations; -import androidx.paging.LivePagedListBuilder; -import androidx.paging.PagedList; -import be.digitalia.fosdem.db.AppDatabase; -import be.digitalia.fosdem.model.StatusEvent; - -import java.util.Arrays; - -public class ExternalBookmarksViewModel extends AndroidViewModel { - - private final AppDatabase appDatabase = AppDatabase.getInstance(getApplication()); - private final MutableLiveData bookmarkIds = new MutableLiveData<>(); - private final LiveData> bookmarks = Transformations.switchMap(bookmarkIds, - bookmarkIds -> new LivePagedListBuilder<>(appDatabase.getScheduleDao().getEvents(bookmarkIds), 20) - .build()); - - public ExternalBookmarksViewModel(@NonNull Application application) { - super(application); - } - - public void setBookmarkIds(@NonNull long[] bookmarkIds) { - if (!Arrays.equals(bookmarkIds, this.bookmarkIds.getValue())) { - this.bookmarkIds.setValue(bookmarkIds); - } - } - - public LiveData> getBookmarks() { - return bookmarks; - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/viewmodels/ExternalBookmarksViewModel.kt b/app/src/main/java/be/digitalia/fosdem/viewmodels/ExternalBookmarksViewModel.kt new file mode 100644 index 0000000..ee15d9a --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/viewmodels/ExternalBookmarksViewModel.kt @@ -0,0 +1,28 @@ +package be.digitalia.fosdem.viewmodels + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.switchMap +import androidx.paging.PagedList +import androidx.paging.toLiveData +import be.digitalia.fosdem.db.AppDatabase +import be.digitalia.fosdem.model.StatusEvent + +class ExternalBookmarksViewModel(application: Application) : AndroidViewModel(application) { + + private val appDatabase = AppDatabase.getInstance(application) + private val bookmarkIdsLiveData = MutableLiveData() + + val bookmarks: LiveData> = bookmarkIdsLiveData.switchMap { bookmarkIds: LongArray -> + appDatabase.scheduleDao.getEvents(bookmarkIds).toLiveData(20) + } + + fun setBookmarkIds(bookmarkIds: LongArray) { + val value = bookmarkIdsLiveData.value + if (value == null || !bookmarkIds.contentEquals(value)) { + bookmarkIdsLiveData.value = bookmarkIds + } + } +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/viewmodels/LiveViewModel.java b/app/src/main/java/be/digitalia/fosdem/viewmodels/LiveViewModel.java deleted file mode 100644 index 18af66d..0000000 --- a/app/src/main/java/be/digitalia/fosdem/viewmodels/LiveViewModel.java +++ /dev/null @@ -1,47 +0,0 @@ -package be.digitalia.fosdem.viewmodels; - -import android.app.Application; -import android.text.format.DateUtils; -import androidx.annotation.NonNull; -import androidx.lifecycle.AndroidViewModel; -import androidx.lifecycle.LiveData; -import androidx.lifecycle.Transformations; -import androidx.paging.LivePagedListBuilder; -import androidx.paging.PagedList; -import be.digitalia.fosdem.db.AppDatabase; -import be.digitalia.fosdem.livedata.LiveDataFactory; -import be.digitalia.fosdem.model.StatusEvent; - -import java.util.concurrent.TimeUnit; - -public class LiveViewModel extends AndroidViewModel { - - static final long NEXT_EVENTS_INTERVAL = 30L * DateUtils.MINUTE_IN_MILLIS; - - private final AppDatabase appDatabase = AppDatabase.getInstance(getApplication()); - private final LiveData heartbeat = LiveDataFactory.interval(1L, TimeUnit.MINUTES); - private final LiveData> nextEvents = Transformations.switchMap(heartbeat, - version -> { - final long now = System.currentTimeMillis(); - return new LivePagedListBuilder<>(appDatabase.getScheduleDao().getEventsWithStartTime(now, now + NEXT_EVENTS_INTERVAL), 20) - .build(); - }); - private final LiveData> eventsInProgress = Transformations.switchMap(heartbeat, - version -> { - final long now = System.currentTimeMillis(); - return new LivePagedListBuilder<>(appDatabase.getScheduleDao().getEventsInProgress(now), 20) - .build(); - }); - - public LiveViewModel(@NonNull Application application) { - super(application); - } - - public LiveData> getNextEvents() { - return nextEvents; - } - - public LiveData> getEventsInProgress() { - return eventsInProgress; - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/viewmodels/LiveViewModel.kt b/app/src/main/java/be/digitalia/fosdem/viewmodels/LiveViewModel.kt new file mode 100644 index 0000000..41720d5 --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/viewmodels/LiveViewModel.kt @@ -0,0 +1,32 @@ +package be.digitalia.fosdem.viewmodels + +import android.app.Application +import android.text.format.DateUtils +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import androidx.lifecycle.switchMap +import androidx.paging.PagedList +import androidx.paging.toLiveData +import be.digitalia.fosdem.db.AppDatabase +import be.digitalia.fosdem.livedata.LiveDataFactory +import be.digitalia.fosdem.model.StatusEvent +import java.util.concurrent.TimeUnit + +class LiveViewModel(application: Application) : AndroidViewModel(application) { + + private val appDatabase = AppDatabase.getInstance(application) + private val heartbeat = LiveDataFactory.interval(1L, TimeUnit.MINUTES) + + val nextEvents: LiveData> = heartbeat.switchMap { + val now = System.currentTimeMillis() + appDatabase.scheduleDao.getEventsWithStartTime(now, now + NEXT_EVENTS_INTERVAL).toLiveData(20) + } + + val eventsInProgress: LiveData> = heartbeat.switchMap { + appDatabase.scheduleDao.getEventsInProgress(System.currentTimeMillis()).toLiveData(20) + } + + companion object { + private const val NEXT_EVENTS_INTERVAL = 30L * DateUtils.MINUTE_IN_MILLIS + } +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/viewmodels/PersonInfoViewModel.java b/app/src/main/java/be/digitalia/fosdem/viewmodels/PersonInfoViewModel.java deleted file mode 100644 index 9c43f10..0000000 --- a/app/src/main/java/be/digitalia/fosdem/viewmodels/PersonInfoViewModel.java +++ /dev/null @@ -1,36 +0,0 @@ -package be.digitalia.fosdem.viewmodels; - -import android.app.Application; -import androidx.annotation.NonNull; -import androidx.lifecycle.AndroidViewModel; -import androidx.lifecycle.LiveData; -import androidx.lifecycle.MutableLiveData; -import androidx.lifecycle.Transformations; -import androidx.paging.LivePagedListBuilder; -import androidx.paging.PagedList; -import be.digitalia.fosdem.db.AppDatabase; -import be.digitalia.fosdem.model.Person; -import be.digitalia.fosdem.model.StatusEvent; - -public class PersonInfoViewModel extends AndroidViewModel { - - private final AppDatabase appDatabase = AppDatabase.getInstance(getApplication()); - private final MutableLiveData person = new MutableLiveData<>(); - private final LiveData> events = Transformations.switchMap(person, - person -> new LivePagedListBuilder<>(appDatabase.getScheduleDao().getEvents(person), 20) - .build()); - - public PersonInfoViewModel(@NonNull Application application) { - super(application); - } - - public void setPerson(@NonNull Person person) { - if (!person.equals(this.person.getValue())) { - this.person.setValue(person); - } - } - - public LiveData> getEvents() { - return events; - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/viewmodels/PersonInfoViewModel.kt b/app/src/main/java/be/digitalia/fosdem/viewmodels/PersonInfoViewModel.kt new file mode 100644 index 0000000..6bd20c2 --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/viewmodels/PersonInfoViewModel.kt @@ -0,0 +1,28 @@ +package be.digitalia.fosdem.viewmodels + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.switchMap +import androidx.paging.PagedList +import androidx.paging.toLiveData +import be.digitalia.fosdem.db.AppDatabase +import be.digitalia.fosdem.model.Person +import be.digitalia.fosdem.model.StatusEvent + +class PersonInfoViewModel(application: Application) : AndroidViewModel(application) { + + private val appDatabase = AppDatabase.getInstance(application) + private val personLiveData = MutableLiveData() + + val events: LiveData> = personLiveData.switchMap { person: Person -> + appDatabase.scheduleDao.getEvents(person).toLiveData(20) + } + + fun setPerson(person: Person) { + if (person != personLiveData.value) { + personLiveData.value = person + } + } +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/viewmodels/PersonsViewModel.java b/app/src/main/java/be/digitalia/fosdem/viewmodels/PersonsViewModel.java deleted file mode 100644 index f217cf7..0000000 --- a/app/src/main/java/be/digitalia/fosdem/viewmodels/PersonsViewModel.java +++ /dev/null @@ -1,27 +0,0 @@ -package be.digitalia.fosdem.viewmodels; - -import android.app.Application; - -import androidx.annotation.NonNull; -import androidx.lifecycle.AndroidViewModel; -import androidx.lifecycle.LiveData; -import androidx.paging.LivePagedListBuilder; -import androidx.paging.PagedList; -import be.digitalia.fosdem.db.AppDatabase; -import be.digitalia.fosdem.model.Person; - -public class PersonsViewModel extends AndroidViewModel { - - private final AppDatabase appDatabase = AppDatabase.getInstance(getApplication()); - private final LiveData> persons - = new LivePagedListBuilder<>(appDatabase.getScheduleDao().getPersons(), 100) - .build(); - - public PersonsViewModel(@NonNull Application application) { - super(application); - } - - public LiveData> getPersons() { - return persons; - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/viewmodels/PersonsViewModel.kt b/app/src/main/java/be/digitalia/fosdem/viewmodels/PersonsViewModel.kt new file mode 100644 index 0000000..218833b --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/viewmodels/PersonsViewModel.kt @@ -0,0 +1,16 @@ +package be.digitalia.fosdem.viewmodels + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import androidx.paging.PagedList +import androidx.paging.toLiveData +import be.digitalia.fosdem.db.AppDatabase +import be.digitalia.fosdem.model.Person + +class PersonsViewModel(application: Application) : AndroidViewModel(application) { + + private val appDatabase = AppDatabase.getInstance(application) + + val persons: LiveData> = appDatabase.scheduleDao.getPersons().toLiveData(100) +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/viewmodels/SearchViewModel.java b/app/src/main/java/be/digitalia/fosdem/viewmodels/SearchViewModel.java deleted file mode 100644 index 0031f4e..0000000 --- a/app/src/main/java/be/digitalia/fosdem/viewmodels/SearchViewModel.java +++ /dev/null @@ -1,61 +0,0 @@ -package be.digitalia.fosdem.viewmodels; - -import android.app.Application; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.lifecycle.AndroidViewModel; -import androidx.lifecycle.LiveData; -import androidx.lifecycle.MutableLiveData; -import androidx.lifecycle.SavedStateHandle; -import androidx.lifecycle.Transformations; -import androidx.paging.LivePagedListBuilder; -import androidx.paging.PagedList; -import be.digitalia.fosdem.db.AppDatabase; -import be.digitalia.fosdem.model.StatusEvent; - -public class SearchViewModel extends AndroidViewModel { - - private static final int MIN_SEARCH_LENGTH = 3; - private static final String STATE_QUERY = "query"; - - private final AppDatabase appDatabase = AppDatabase.getInstance(getApplication()); - private final SavedStateHandle state; - private final LiveData query; - private final LiveData> results; - - public SearchViewModel(@NonNull Application application, @NonNull SavedStateHandle state) { - super(application); - this.state = state; - query = state.getLiveData(STATE_QUERY); - results = Transformations.switchMap(query, - query -> { - if (isQueryTooShort(query)) { - MutableLiveData> emptyResult = new MutableLiveData<>(); - emptyResult.setValue(null); - return emptyResult; - } - return new LivePagedListBuilder<>(appDatabase.getScheduleDao().getSearchResults(query), 20) - .build(); - }); - } - - public void setQuery(@NonNull String query) { - if (!query.equals(this.query.getValue())) { - state.set(STATE_QUERY, query); - } - } - - @Nullable - public String getQuery() { - return query.getValue(); - } - - public static boolean isQueryTooShort(String value) { - return (value == null) || (value.length() < MIN_SEARCH_LENGTH); - } - - public LiveData> getResults() { - return results; - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/viewmodels/SearchViewModel.kt b/app/src/main/java/be/digitalia/fosdem/viewmodels/SearchViewModel.kt new file mode 100644 index 0000000..8808772 --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/viewmodels/SearchViewModel.kt @@ -0,0 +1,42 @@ +package be.digitalia.fosdem.viewmodels + +import android.app.Application +import androidx.lifecycle.* +import androidx.paging.PagedList +import androidx.paging.toLiveData +import be.digitalia.fosdem.db.AppDatabase +import be.digitalia.fosdem.model.StatusEvent + +class SearchViewModel(application: Application, private val state: SavedStateHandle) : AndroidViewModel(application) { + + private val appDatabase = AppDatabase.getInstance(application) + private val queryLiveData: LiveData = state.getLiveData(STATE_QUERY) + + sealed class Result { + object QueryTooShort : Result() + class Success(val list: PagedList) : Result() + } + + val results: LiveData = queryLiveData.switchMap { query -> + if (query == null || query.length < SEARCH_QUERY_MIN_LENGTH) { + MutableLiveData(Result.QueryTooShort) + } else { + appDatabase.scheduleDao.getSearchResults(query) + .toLiveData(20) + .map { pagedList -> Result.Success(pagedList) } + } + } + + var query: String? + get() = queryLiveData.value + set(value) { + if (value != queryLiveData.value) { + state[STATE_QUERY] = value + } + } + + companion object { + private const val SEARCH_QUERY_MIN_LENGTH = 3 + private const val STATE_QUERY = "query" + } +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/viewmodels/TrackScheduleEventViewModel.java b/app/src/main/java/be/digitalia/fosdem/viewmodels/TrackScheduleEventViewModel.java deleted file mode 100644 index aec37a3..0000000 --- a/app/src/main/java/be/digitalia/fosdem/viewmodels/TrackScheduleEventViewModel.java +++ /dev/null @@ -1,45 +0,0 @@ -package be.digitalia.fosdem.viewmodels; - -import android.app.Application; -import androidx.annotation.NonNull; -import androidx.core.util.Pair; -import androidx.lifecycle.AndroidViewModel; -import androidx.lifecycle.LiveData; -import androidx.lifecycle.MutableLiveData; -import androidx.lifecycle.Transformations; -import be.digitalia.fosdem.db.AppDatabase; -import be.digitalia.fosdem.model.Day; -import be.digitalia.fosdem.model.Event; -import be.digitalia.fosdem.model.Track; - -import java.util.List; - -public class TrackScheduleEventViewModel extends AndroidViewModel { - - private final AppDatabase appDatabase = AppDatabase.getInstance(getApplication()); - private final MutableLiveData> dayTrack = new MutableLiveData<>(); - private final LiveData> scheduleSnapshot = Transformations.switchMap(dayTrack, - dayTrack -> { - final MutableLiveData> resultLiveData = new MutableLiveData<>(); - appDatabase.getQueryExecutor().execute(() -> { - final List result = appDatabase.getScheduleDao().getEventsSnapshot(dayTrack.first, dayTrack.second); - resultLiveData.postValue(result); - }); - return resultLiveData; - }); - - public TrackScheduleEventViewModel(@NonNull Application application) { - super(application); - } - - public void setTrack(@NonNull Day day, @NonNull Track track) { - Pair dayTrack = Pair.create(day, track); - if (!dayTrack.equals(this.dayTrack.getValue())) { - this.dayTrack.setValue(dayTrack); - } - } - - public LiveData> getScheduleSnapshot() { - return scheduleSnapshot; - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/viewmodels/TrackScheduleEventViewModel.kt b/app/src/main/java/be/digitalia/fosdem/viewmodels/TrackScheduleEventViewModel.kt new file mode 100644 index 0000000..ce6d06e --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/viewmodels/TrackScheduleEventViewModel.kt @@ -0,0 +1,32 @@ +package be.digitalia.fosdem.viewmodels + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.switchMap +import be.digitalia.fosdem.db.AppDatabase +import be.digitalia.fosdem.model.Day +import be.digitalia.fosdem.model.Event +import be.digitalia.fosdem.model.Track + +class TrackScheduleEventViewModel(application: Application) : AndroidViewModel(application) { + + private val appDatabase = AppDatabase.getInstance(application) + private val dayTrackLiveData = MutableLiveData>() + + val scheduleSnapshot: LiveData> = dayTrackLiveData.switchMap { (day, track) -> + MutableLiveData>().also { + appDatabase.queryExecutor.execute { + it.postValue(appDatabase.scheduleDao.getEventsSnapshot(day, track)) + } + } + } + + fun setDayAndTrack(day: Day, track: Track) { + val dayTrack = day to track + if (dayTrack != dayTrackLiveData.value) { + dayTrackLiveData.value = dayTrack + } + } +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/viewmodels/TrackScheduleViewModel.java b/app/src/main/java/be/digitalia/fosdem/viewmodels/TrackScheduleViewModel.java deleted file mode 100644 index 2bc8216..0000000 --- a/app/src/main/java/be/digitalia/fosdem/viewmodels/TrackScheduleViewModel.java +++ /dev/null @@ -1,68 +0,0 @@ -package be.digitalia.fosdem.viewmodels; - -import android.app.Application; -import android.text.format.DateUtils; - -import androidx.annotation.NonNull; -import androidx.core.util.Pair; -import androidx.lifecycle.AndroidViewModel; -import androidx.lifecycle.LiveData; -import androidx.lifecycle.MutableLiveData; -import androidx.lifecycle.Transformations; - -import java.util.List; -import java.util.concurrent.TimeUnit; - -import be.digitalia.fosdem.db.AppDatabase; -import be.digitalia.fosdem.livedata.LiveDataFactory; -import be.digitalia.fosdem.model.Day; -import be.digitalia.fosdem.model.StatusEvent; -import be.digitalia.fosdem.model.Track; - -public class TrackScheduleViewModel extends AndroidViewModel { - - private static final long REFRESH_TIME_INTERVAL = DateUtils.MINUTE_IN_MILLIS; - - private final AppDatabase appDatabase = AppDatabase.getInstance(getApplication()); - private final MutableLiveData> dayTrack = new MutableLiveData<>(); - - private final LiveData> schedule = Transformations.switchMap(dayTrack, - dayTrack -> appDatabase.getScheduleDao().getEvents(dayTrack.first, dayTrack.second)); - - // Auto refresh during the day passed as argument - private final LiveData scheduler = Transformations.switchMap(dayTrack, dayTrack -> { - final long dayStart = dayTrack.first.getDate().getTime(); - return LiveDataFactory.scheduler(dayStart, dayStart + android.text.format.DateUtils.DAY_IN_MILLIS); - }); - private final LiveData currentTime = Transformations.switchMap(scheduler, isOn -> { - if (isOn) { - return Transformations.map( - LiveDataFactory.interval(REFRESH_TIME_INTERVAL, TimeUnit.MILLISECONDS), - count -> System.currentTimeMillis() - ); - } - return new MutableLiveData<>(-1L); - }); - - public TrackScheduleViewModel(@NonNull Application application) { - super(application); - } - - public void setTrack(@NonNull Day day, @NonNull Track track) { - Pair dayTrack = Pair.create(day, track); - if (!dayTrack.equals(this.dayTrack.getValue())) { - this.dayTrack.setValue(dayTrack); - } - } - - public LiveData> getSchedule() { - return schedule; - } - - /** - * @return The current time during the target day, or -1 outside of the target day. - */ - public LiveData getCurrentTime() { - return currentTime; - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/viewmodels/TrackScheduleViewModel.kt b/app/src/main/java/be/digitalia/fosdem/viewmodels/TrackScheduleViewModel.kt new file mode 100644 index 0000000..408fb66 --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/viewmodels/TrackScheduleViewModel.kt @@ -0,0 +1,51 @@ +package be.digitalia.fosdem.viewmodels + +import android.app.Application +import android.text.format.DateUtils +import androidx.lifecycle.* +import be.digitalia.fosdem.db.AppDatabase +import be.digitalia.fosdem.livedata.LiveDataFactory +import be.digitalia.fosdem.model.Day +import be.digitalia.fosdem.model.StatusEvent +import be.digitalia.fosdem.model.Track +import java.util.concurrent.TimeUnit + +class TrackScheduleViewModel(application: Application) : AndroidViewModel(application) { + + private val appDatabase = AppDatabase.getInstance(application) + private val dayTrackLiveData = MutableLiveData>() + + val schedule: LiveData> = dayTrackLiveData.switchMap { (day, track) -> + appDatabase.scheduleDao.getEvents(day, track) + } + + /** + * @return The current time during the target day, or -1 outside of the target day. + */ + val currentTime: LiveData = dayTrackLiveData + .switchMap { (day, _) -> + // Auto refresh during the day passed as argument + val dayStart = day.date.time + LiveDataFactory.scheduler(dayStart, dayStart + DateUtils.DAY_IN_MILLIS) + } + .switchMap { isOn -> + if (isOn) { + LiveDataFactory.interval(REFRESH_TIME_INTERVAL, TimeUnit.MILLISECONDS).map { + System.currentTimeMillis() + } + } else { + MutableLiveData(-1L) + } + } + + fun setDayAndTrack(day: Day, track: Track) { + val dayTrack = day to track + if (dayTrack != dayTrackLiveData.value) { + dayTrackLiveData.value = dayTrack + } + } + + companion object { + private const val REFRESH_TIME_INTERVAL = DateUtils.MINUTE_IN_MILLIS + } +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/viewmodels/TracksViewModel.java b/app/src/main/java/be/digitalia/fosdem/viewmodels/TracksViewModel.java deleted file mode 100644 index 4e729e7..0000000 --- a/app/src/main/java/be/digitalia/fosdem/viewmodels/TracksViewModel.java +++ /dev/null @@ -1,35 +0,0 @@ -package be.digitalia.fosdem.viewmodels; - -import android.app.Application; -import androidx.annotation.NonNull; -import androidx.lifecycle.AndroidViewModel; -import androidx.lifecycle.LiveData; -import androidx.lifecycle.MutableLiveData; -import androidx.lifecycle.Transformations; -import be.digitalia.fosdem.db.AppDatabase; -import be.digitalia.fosdem.model.Day; -import be.digitalia.fosdem.model.Track; - -import java.util.List; - -public class TracksViewModel extends AndroidViewModel { - - private final AppDatabase appDatabase = AppDatabase.getInstance(getApplication()); - private final MutableLiveData day = new MutableLiveData<>(); - private final LiveData> tracks = Transformations.switchMap(day, - day -> appDatabase.getScheduleDao().getTracks(day)); - - public TracksViewModel(@NonNull Application application) { - super(application); - } - - public void setDay(@NonNull Day day) { - if (!day.equals(this.day.getValue())) { - this.day.setValue(day); - } - } - - public LiveData> getTracks() { - return tracks; - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/viewmodels/TracksViewModel.kt b/app/src/main/java/be/digitalia/fosdem/viewmodels/TracksViewModel.kt new file mode 100644 index 0000000..1d2e8d4 --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/viewmodels/TracksViewModel.kt @@ -0,0 +1,26 @@ +package be.digitalia.fosdem.viewmodels + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.switchMap +import be.digitalia.fosdem.db.AppDatabase +import be.digitalia.fosdem.model.Day +import be.digitalia.fosdem.model.Track + +class TracksViewModel(application: Application) : AndroidViewModel(application) { + + private val appDatabase = AppDatabase.getInstance(application) + private val dayLiveData = MutableLiveData() + + val tracks: LiveData> = dayLiveData.switchMap { day: Day -> + appDatabase.scheduleDao.getTracks(day) + } + + fun setDay(day: Day) { + if (day != dayLiveData.value) { + dayLiveData.value = day + } + } +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/widgets/BookmarkStatusAdapter.java b/app/src/main/java/be/digitalia/fosdem/widgets/BookmarkStatusAdapter.java deleted file mode 100644 index 2195229..0000000 --- a/app/src/main/java/be/digitalia/fosdem/widgets/BookmarkStatusAdapter.java +++ /dev/null @@ -1,39 +0,0 @@ -package be.digitalia.fosdem.widgets; - -import android.widget.ImageButton; - -import androidx.annotation.NonNull; -import androidx.lifecycle.LifecycleOwner; - -import be.digitalia.fosdem.R; -import be.digitalia.fosdem.viewmodels.BookmarkStatusViewModel; - -public class BookmarkStatusAdapter { - - private BookmarkStatusAdapter() { - } - - /** - * Connect an ImageButton to a BookmarkStatusViewModel - * to update its icon according to the current status and trigger a bookmark toggle on click. - */ - public static void setupWithImageButton(@NonNull final BookmarkStatusViewModel viewModel, @NonNull LifecycleOwner owner, - @NonNull final ImageButton imageButton) { - imageButton.setOnClickListener(v -> viewModel.toggleBookmarkStatus()); - viewModel.getBookmarkStatus().observe(owner, bookmarkStatus -> { - if (bookmarkStatus == null) { - imageButton.setEnabled(false); - imageButton.setSelected(false); - } else { - final boolean wasEnabled = imageButton.isEnabled(); - imageButton.setEnabled(true); - imageButton.setContentDescription(imageButton.getContext().getString(bookmarkStatus.isBookmarked() ? R.string.remove_bookmark : R.string.add_bookmark)); - imageButton.setSelected(bookmarkStatus.isBookmarked()); - // Only animate updates, when the button was already enabled - if (!(bookmarkStatus.isUpdate() && wasEnabled)) { - imageButton.jumpDrawablesToCurrentState(); - } - } - }); - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/widgets/BookmarkStatusAdapter.kt b/app/src/main/java/be/digitalia/fosdem/widgets/BookmarkStatusAdapter.kt new file mode 100644 index 0000000..2be8dd2 --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/widgets/BookmarkStatusAdapter.kt @@ -0,0 +1,31 @@ +package be.digitalia.fosdem.widgets + +import android.widget.ImageButton +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.observe +import be.digitalia.fosdem.R +import be.digitalia.fosdem.model.BookmarkStatus +import be.digitalia.fosdem.viewmodels.BookmarkStatusViewModel + +/** + * Connect an ImageButton to a BookmarkStatusViewModel + * to update its icon according to the current status and trigger a bookmark toggle on click. + */ +fun ImageButton.setupBookmarkStatus(viewModel: BookmarkStatusViewModel, owner: LifecycleOwner) { + setOnClickListener { viewModel.toggleBookmarkStatus() } + viewModel.bookmarkStatus.observe(owner) { bookmarkStatus: BookmarkStatus? -> + if (bookmarkStatus == null) { + isEnabled = false + isSelected = false + } else { + val wasEnabled = isEnabled + isEnabled = true + contentDescription = context.getString(if (bookmarkStatus.isBookmarked) R.string.remove_bookmark else R.string.add_bookmark) + isSelected = bookmarkStatus.isBookmarked + // Only animate updates, when the button was already enabled + if (!(bookmarkStatus.isUpdate && wasEnabled)) { + jumpDrawablesToCurrentState() + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/widgets/ContentLoadingProgressBar.java b/app/src/main/java/be/digitalia/fosdem/widgets/ContentLoadingProgressBar.java deleted file mode 100644 index 8f23ecd..0000000 --- a/app/src/main/java/be/digitalia/fosdem/widgets/ContentLoadingProgressBar.java +++ /dev/null @@ -1,113 +0,0 @@ -package be.digitalia.fosdem.widgets; - -import android.content.Context; -import android.os.SystemClock; -import android.util.AttributeSet; -import android.view.View; -import android.widget.ProgressBar; - -/** - * ContentLoadingProgressBar implements a ProgressBar that waits a minimum time to be - * dismissed before showing. Once visible, the progress bar will be visible for - * a minimum amount of time to avoid "flashes" in the UI when an event could take - * a largely variable time to complete (from none, to a user perceivable amount). - *

- * This version is similar to the support library version but implemented "the right way". - * - * @author Christophe Beyls - */ -public class ContentLoadingProgressBar extends ProgressBar { - private static final long MIN_SHOW_TIME = 500L; // ms - private static final long MIN_DELAY = 500L; // ms - - private boolean mIsAttachedToWindow = false; - private boolean mIsShown; - long mStartTime = -1L; - - private final Runnable mDelayedHide = () -> { - setVisibility(View.GONE); - mStartTime = -1L; - }; - - private final Runnable mDelayedShow = () -> { - mStartTime = SystemClock.uptimeMillis(); - setVisibility(View.VISIBLE); - }; - - public ContentLoadingProgressBar(Context context) { - this(context, null, 0); - } - - public ContentLoadingProgressBar(Context context, AttributeSet attrs) { - this(context, attrs, 0); - } - - public ContentLoadingProgressBar(Context context, AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - mIsShown = getVisibility() == View.VISIBLE; - } - - @Override - protected void onAttachedToWindow() { - super.onAttachedToWindow(); - mIsAttachedToWindow = true; - if (mIsShown && (getVisibility() != View.VISIBLE)) { - postDelayed(mDelayedShow, MIN_DELAY); - } - } - - @Override - protected void onDetachedFromWindow() { - super.onDetachedFromWindow(); - mIsAttachedToWindow = false; - removeCallbacks(mDelayedHide); - removeCallbacks(mDelayedShow); - if (!mIsShown && mStartTime != -1L) { - setVisibility(View.GONE); - } - mStartTime = -1L; - } - - /** - * Hide the progress view if it is visible. The progress view will not be - * hidden until it has been shown for at least a minimum show time. If the - * progress view was not yet visible, cancels showing the progress view. - */ - public void hide() { - if (mIsShown) { - mIsShown = false; - if (mIsAttachedToWindow) { - removeCallbacks(mDelayedShow); - } - long diff = SystemClock.uptimeMillis() - mStartTime; - if (mStartTime == -1L || diff >= MIN_SHOW_TIME) { - // The progress spinner has been shown long enough - // OR was not shown yet. If it wasn't shown yet, - // it will just never be shown. - setVisibility(View.GONE); - mStartTime = -1L; - } else { - // The progress spinner is shown, but not long enough, - // so put a delayed message in to hide it when its been - // shown long enough. - postDelayed(mDelayedHide, MIN_SHOW_TIME - diff); - } - } - } - - /** - * Show the progress view after waiting for a minimum delay. If - * during that time, hide() is called, the view is never made visible. - */ - public void show() { - if (!mIsShown) { - mIsShown = true; - if (mIsAttachedToWindow) { - removeCallbacks(mDelayedHide); - if (mStartTime == -1L) { - postDelayed(mDelayedShow, MIN_DELAY); - } - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/widgets/ContentLoadingProgressBar.kt b/app/src/main/java/be/digitalia/fosdem/widgets/ContentLoadingProgressBar.kt new file mode 100644 index 0000000..cf7dd33 --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/widgets/ContentLoadingProgressBar.kt @@ -0,0 +1,102 @@ +package be.digitalia.fosdem.widgets + +import android.content.Context +import android.os.SystemClock +import android.util.AttributeSet +import android.widget.ProgressBar +import androidx.core.view.ViewCompat +import androidx.core.view.isVisible + +/** + * ContentLoadingProgressBar implements a ProgressBar that waits a minimum time to be + * dismissed before showing. Once visible, the progress bar will be visible for + * a minimum amount of time to avoid "flashes" in the UI when an event could take + * a largely variable time to complete (from none, to a user perceivable amount). + * + * + * This version is similar to the support library version but implemented "the right way". + * + * @author Christophe Beyls + */ +class ContentLoadingProgressBar : ProgressBar { + + constructor(context: Context) : super(context) + constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) + constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) + + private var shouldBeVisible: Boolean = isVisible + private var startTime = -1L + private val delayedHide = Runnable { + isVisible = false + startTime = -1L + } + private val delayedShow = Runnable { + startTime = SystemClock.uptimeMillis() + isVisible = true + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + if (shouldBeVisible && !isVisible) { + postDelayed(delayedShow, MIN_DELAY) + } + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + removeCallbacks(delayedHide) + removeCallbacks(delayedShow) + if (!shouldBeVisible && startTime != -1L) { + isVisible = false + } + startTime = -1L + } + + /** + * Hide the progress view if it is visible. The progress view will not be + * hidden until it has been shown for at least a minimum show time. If the + * progress view was not yet visible, cancels showing the progress view. + */ + fun hide() { + if (shouldBeVisible) { + shouldBeVisible = false + if (ViewCompat.isAttachedToWindow(this)) { + removeCallbacks(delayedShow) + } + val diff = SystemClock.uptimeMillis() - startTime + if (startTime == -1L || diff >= MIN_SHOW_TIME) { + // The progress spinner has been shown long enough + // OR was not shown yet. If it wasn't shown yet, + // it will just never be shown. + isVisible = false + startTime = -1L + } else { + // The progress spinner is shown, but not long enough, + // so put a delayed message in to hide it when its been + // shown long enough. + postDelayed(delayedHide, MIN_SHOW_TIME - diff) + } + } + } + + /** + * Show the progress view after waiting for a minimum delay. If + * during that time, hide() is called, the view is never made visible. + */ + fun show() { + if (!shouldBeVisible) { + shouldBeVisible = true + if (ViewCompat.isAttachedToWindow(this)) { + removeCallbacks(delayedHide) + if (startTime == -1L) { + postDelayed(delayedShow, MIN_DELAY) + } + } + } + } + + companion object { + private const val MIN_SHOW_TIME = 500L // ms + private const val MIN_DELAY = 500L // ms + } +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/widgets/MaterialHorizontalProgressBar.java b/app/src/main/java/be/digitalia/fosdem/widgets/MaterialHorizontalProgressBar.java deleted file mode 100644 index 55140bb..0000000 --- a/app/src/main/java/be/digitalia/fosdem/widgets/MaterialHorizontalProgressBar.java +++ /dev/null @@ -1,67 +0,0 @@ -package be.digitalia.fosdem.widgets; - -import android.content.Context; -import android.content.res.TypedArray; -import android.graphics.Color; -import android.graphics.PorterDuff; -import android.graphics.PorterDuffColorFilter; -import android.graphics.drawable.Drawable; -import android.graphics.drawable.LayerDrawable; -import android.os.Build; -import android.util.AttributeSet; -import android.widget.ProgressBar; - -import androidx.appcompat.content.res.AppCompatResources; -import androidx.core.content.ContextCompat; - -import be.digitalia.fosdem.R; - -public class MaterialHorizontalProgressBar extends ProgressBar { - - public MaterialHorizontalProgressBar(Context context) { - super(context, null, R.attr.materialHorizontalProgressBarStyle); - init(); - } - - public MaterialHorizontalProgressBar(Context context, AttributeSet attrs) { - super(context, attrs, R.attr.materialHorizontalProgressBarStyle); - init(); - } - - public MaterialHorizontalProgressBar(Context context, AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - init(); - } - - private void init() { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { - setProgressDrawable(createProgressDrawable()); - setIndeterminateDrawable(AppCompatResources.getDrawable(getContext(), R.drawable.avd_progress_indeterminate_horizontal)); - } - } - - private Drawable createProgressDrawable() { - final LayerDrawable layerDrawable = (LayerDrawable) ContextCompat.getDrawable(getContext(), R.drawable.progress_horizontal_material); - if (layerDrawable != null) { - final TypedArray a = getContext().getTheme().obtainStyledAttributes( - new int[]{R.attr.colorControlNormal, R.attr.colorControlActivated, android.R.attr.disabledAlpha} - ); - final int colorControlNormal = a.getColor(0, Color.TRANSPARENT); - final int colorControlActivated = a.getColor(1, Color.TRANSPARENT); - final int disabledAlpha = Math.max(0, Math.min(255, Math.round(a.getFloat(2, 0f) * 255f))); - a.recycle(); - - final Drawable backgroundDrawable = layerDrawable.findDrawableByLayerId(android.R.id.background); - backgroundDrawable.setAlpha(disabledAlpha); - backgroundDrawable.mutate().setColorFilter(new PorterDuffColorFilter(colorControlNormal, PorterDuff.Mode.SRC_IN)); - - final Drawable secondaryProgressDrawable = layerDrawable.findDrawableByLayerId(android.R.id.secondaryProgress); - secondaryProgressDrawable.setAlpha(disabledAlpha); - secondaryProgressDrawable.mutate().setColorFilter(new PorterDuffColorFilter(colorControlActivated, PorterDuff.Mode.SRC_IN)); - - final Drawable progressDrawable = layerDrawable.findDrawableByLayerId(android.R.id.progress); - progressDrawable.mutate().setColorFilter(new PorterDuffColorFilter(colorControlActivated, PorterDuff.Mode.SRC_IN)); - } - return layerDrawable; - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/widgets/MaterialHorizontalProgressBar.kt b/app/src/main/java/be/digitalia/fosdem/widgets/MaterialHorizontalProgressBar.kt new file mode 100644 index 0000000..79d7ef4 --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/widgets/MaterialHorizontalProgressBar.kt @@ -0,0 +1,53 @@ +package be.digitalia.fosdem.widgets + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Color +import android.graphics.PorterDuff +import android.graphics.PorterDuffColorFilter +import android.graphics.drawable.Drawable +import android.graphics.drawable.LayerDrawable +import android.os.Build +import android.util.AttributeSet +import android.widget.ProgressBar +import androidx.appcompat.content.res.AppCompatResources +import androidx.core.content.ContextCompat +import be.digitalia.fosdem.R +import kotlin.math.roundToInt + +class MaterialHorizontalProgressBar @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = R.attr.materialHorizontalProgressBarStyle) + : ProgressBar(context, attrs, defStyleAttr) { + + init { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + progressDrawable = createProgressDrawable() + indeterminateDrawable = AppCompatResources.getDrawable(context, R.drawable.avd_progress_indeterminate_horizontal) + } + } + + @SuppressLint("ResourceType") + private fun createProgressDrawable(): Drawable? { + return (ContextCompat.getDrawable(context, R.drawable.progress_horizontal_material) as? LayerDrawable)?.apply { + val a = context.theme.obtainStyledAttributes(intArrayOf(R.attr.colorControlNormal, R.attr.colorControlActivated, android.R.attr.disabledAlpha)) + val colorControlNormal = a.getColor(0, Color.TRANSPARENT) + val colorControlActivated = a.getColor(1, Color.TRANSPARENT) + val disabledAlpha = (a.getFloat(2, 0f) * 255f).roundToInt().coerceIn(0, 255) + a.recycle() + + with(findDrawableByLayerId(android.R.id.background)) { + mutate() + alpha = disabledAlpha + colorFilter = PorterDuffColorFilter(colorControlNormal, PorterDuff.Mode.SRC_IN) + } + with(findDrawableByLayerId(android.R.id.secondaryProgress)) { + mutate() + alpha = disabledAlpha + colorFilter = PorterDuffColorFilter(colorControlActivated, PorterDuff.Mode.SRC_IN) + } + with(findDrawableByLayerId(android.R.id.progress)) { + mutate() + colorFilter = PorterDuffColorFilter(colorControlActivated, PorterDuff.Mode.SRC_IN) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/widgets/MultiChoiceHelper.java b/app/src/main/java/be/digitalia/fosdem/widgets/MultiChoiceHelper.java deleted file mode 100644 index c1ddd27..0000000 --- a/app/src/main/java/be/digitalia/fosdem/widgets/MultiChoiceHelper.java +++ /dev/null @@ -1,458 +0,0 @@ -package be.digitalia.fosdem.widgets; - -import android.os.Bundle; -import android.os.Parcel; -import android.os.Parcelable; -import android.util.SparseBooleanArray; -import android.view.Menu; -import android.view.MenuItem; -import android.view.View; -import android.widget.Checkable; - -import androidx.annotation.NonNull; -import androidx.appcompat.app.AppCompatActivity; -import androidx.appcompat.view.ActionMode; -import androidx.collection.LongSparseArray; -import androidx.lifecycle.DefaultLifecycleObserver; -import androidx.lifecycle.LifecycleOwner; -import androidx.recyclerview.widget.RecyclerView; -import androidx.savedstate.SavedStateRegistryOwner; - -/** - * Helper class to reproduce ListView's modal MultiChoice mode with a RecyclerView. - * Declare and use this class from inside your Adapter. - * - * @author Christophe Beyls - */ -public class MultiChoiceHelper { - - /** - * A handy ViewHolder base class which works with the MultiChoiceHelper - * and reproduces the default behavior of a ListView. - */ - public static abstract class ViewHolder extends RecyclerView.ViewHolder { - - View.OnClickListener clickListener; - final MultiChoiceHelper multiChoiceHelper; - - public ViewHolder(@NonNull View itemView, @NonNull MultiChoiceHelper helper) { - super(itemView); - multiChoiceHelper = helper; - itemView.setOnClickListener(view -> { - if (isMultiChoiceActive()) { - int position = getAdapterPosition(); - if (position != RecyclerView.NO_POSITION) { - multiChoiceHelper.toggleItemChecked(position); - } - } else { - if (clickListener != null) { - clickListener.onClick(view); - } - } - }); - itemView.setOnLongClickListener(view -> { - if (isMultiChoiceActive()) { - return false; - } - int position = getAdapterPosition(); - if (position != RecyclerView.NO_POSITION) { - multiChoiceHelper.setItemChecked(position, true); - } - return true; - }); - } - - public void setOnClickListener(View.OnClickListener clickListener) { - this.clickListener = clickListener; - } - - public void bindSelection() { - int position = getAdapterPosition(); - if (position != RecyclerView.NO_POSITION) { - final boolean isChecked = multiChoiceHelper.isItemChecked(position); - if (itemView instanceof Checkable) { - ((Checkable) itemView).setChecked(isChecked); - } else { - itemView.setActivated(isChecked); - } - } - } - - public boolean isMultiChoiceActive() { - return multiChoiceHelper.getCheckedItemCount() > 0; - } - } - - public interface MultiChoiceModeListener extends ActionMode.Callback { - /** - * Called when an item is checked or unchecked during selection mode. - * - * @param mode The {@link ActionMode} providing the selection startSupportActionModemode - * @param position Adapter position of the item that was checked or unchecked - * @param id Adapter ID of the item that was checked or unchecked - * @param checked true if the item is now checked, false - * if the item is now unchecked. - */ - void onItemCheckedStateChanged(ActionMode mode, int position, long id, boolean checked); - } - - public static final Object SELECTION_PAYLOAD = new Object(); - - private static final String STATE_KEY = "MultiChoiceHelper"; - private static final String PARCELABLE_KEY = "saved_state"; - private static final int CHECK_POSITION_SEARCH_DISTANCE = 20; - - private final AppCompatActivity activity; - private final RecyclerView.Adapter adapter; - private SparseBooleanArray checkStates; - private LongSparseArray checkedIdStates; - private int checkedItemCount = 0; - private MultiChoiceModeWrapper multiChoiceModeCallback; - ActionMode choiceActionMode; - - /** - * Make sure this constructor is called before setting the adapter on the RecyclerView - * so this class will be notified before the RecyclerView in case of data set changes. - */ - public MultiChoiceHelper(@NonNull AppCompatActivity activity, - @NonNull SavedStateRegistryOwner owner, - @NonNull RecyclerView.Adapter adapter) { - this.activity = activity; - this.adapter = adapter; - adapter.registerAdapterDataObserver(new AdapterDataSetObserver()); - checkStates = new SparseBooleanArray(0); - if (adapter.hasStableIds()) { - checkedIdStates = new LongSparseArray<>(0); - } - - final Bundle restoreBundle = owner.getSavedStateRegistry().consumeRestoredStateForKey(STATE_KEY); - if (restoreBundle != null) { - SavedState savedState = restoreBundle.getParcelable(PARCELABLE_KEY); - checkedItemCount = savedState.checkedItemCount; - checkStates = savedState.checkStates; - checkedIdStates = savedState.checkedIdStates; - - // Try early restoration, otherwise do it when items are inserted - if (adapter.getItemCount() > 0) { - onAdapterPopulated(); - } - } - owner.getSavedStateRegistry().registerSavedStateProvider(STATE_KEY, () -> { - final Bundle saveBundle = new Bundle(); - SavedState savedState = new SavedState(); - savedState.checkedItemCount = checkedItemCount; - savedState.checkStates = checkStates.clone(); - if (checkedIdStates != null) { - savedState.checkedIdStates = checkedIdStates.clone(); - } - saveBundle.putParcelable(PARCELABLE_KEY, savedState); - return saveBundle; - }); - owner.getLifecycle().addObserver(new DefaultLifecycleObserver() { - @Override - public void onDestroy(@NonNull LifecycleOwner owner) { - clearChoices(); - } - }); - } - - public void setMultiChoiceModeListener(MultiChoiceModeListener listener) { - if (listener == null) { - multiChoiceModeCallback = null; - return; - } - if (multiChoiceModeCallback == null) { - multiChoiceModeCallback = new MultiChoiceModeWrapper(); - } - multiChoiceModeCallback.setWrapped(listener); - } - - public int getCheckedItemCount() { - return checkedItemCount; - } - - public boolean isItemChecked(int position) { - return checkStates.get(position); - } - - public SparseBooleanArray getCheckedItemPositions() { - return checkStates; - } - - public long[] getCheckedItemIds() { - final LongSparseArray idStates = checkedIdStates; - if (idStates == null) { - return new long[0]; - } - - final int count = idStates.size(); - final long[] ids = new long[count]; - - for (int i = 0; i < count; i++) { - ids[i] = idStates.keyAt(i); - } - - return ids; - } - - public void clearChoices() { - if (checkedItemCount > 0) { - final int start = checkStates.keyAt(0); - final int end = checkStates.keyAt(checkStates.size() - 1); - checkStates.clear(); - if (checkedIdStates != null) { - checkedIdStates.clear(); - } - checkedItemCount = 0; - - adapter.notifyItemRangeChanged(start, end - start + 1, SELECTION_PAYLOAD); - - if (choiceActionMode != null) { - choiceActionMode.finish(); - } - } - } - - public void setItemChecked(int position, boolean value) { - // Start selection mode if needed. We don't need it if we're unchecking something. - if (value) { - startSupportActionModeIfNeeded(); - } - - boolean oldValue = checkStates.get(position); - checkStates.put(position, value); - - if (oldValue != value) { - final long id = adapter.getItemId(position); - - if (checkedIdStates != null) { - if (value) { - checkedIdStates.put(id, position); - } else { - checkedIdStates.remove(id); - } - } - - if (value) { - checkedItemCount++; - } else { - checkedItemCount--; - } - - adapter.notifyItemChanged(position, SELECTION_PAYLOAD); - - if (choiceActionMode != null) { - multiChoiceModeCallback.onItemCheckedStateChanged(choiceActionMode, position, id, value); - if (checkedItemCount == 0) { - choiceActionMode.finish(); - } - } - } - } - - public void toggleItemChecked(int position) { - setItemChecked(position, !isItemChecked(position)); - } - - void onAdapterPopulated() { - confirmCheckedPositions(); - if (checkedItemCount > 0) { - startSupportActionModeIfNeeded(); - } - } - - private void startSupportActionModeIfNeeded() { - if (choiceActionMode == null) { - if (multiChoiceModeCallback == null) { - throw new IllegalStateException("No callback set"); - } - choiceActionMode = activity.startSupportActionMode(multiChoiceModeCallback); - } - } - - public static class SavedState implements Parcelable { - - int checkedItemCount; - SparseBooleanArray checkStates; - LongSparseArray checkedIdStates; - - SavedState() { - } - - SavedState(Parcel in) { - checkedItemCount = in.readInt(); - checkStates = in.readSparseBooleanArray(); - final int n = in.readInt(); - if (n >= 0) { - checkedIdStates = new LongSparseArray<>(n); - for (int i = 0; i < n; i++) { - final long key = in.readLong(); - final int value = in.readInt(); - checkedIdStates.append(key, value); - } - } - } - - @Override - public void writeToParcel(Parcel out, int flags) { - out.writeInt(checkedItemCount); - out.writeSparseBooleanArray(checkStates); - final int n = checkedIdStates != null ? checkedIdStates.size() : -1; - out.writeInt(n); - for (int i = 0; i < n; i++) { - out.writeLong(checkedIdStates.keyAt(i)); - out.writeInt(checkedIdStates.valueAt(i)); - } - } - - @Override - public int describeContents() { - return 0; - } - - public static final Creator CREATOR = new Creator() { - @Override - public SavedState createFromParcel(Parcel in) { - return new SavedState(in); - } - - @Override - public SavedState[] newArray(int size) { - return new SavedState[size]; - } - }; - } - - void confirmCheckedPositions() { - if (checkedItemCount == 0) { - return; - } - - final int itemCount = adapter.getItemCount(); - boolean checkedCountChanged = false; - - if (itemCount == 0) { - // Optimized path for empty adapter: remove all items. - checkStates.clear(); - if (checkedIdStates != null) { - checkedIdStates.clear(); - } - checkedItemCount = 0; - checkedCountChanged = true; - } else if (checkedIdStates != null) { - // Clear out the positional check states, we'll rebuild it below from IDs. - checkStates.clear(); - - for (int checkedIndex = 0; checkedIndex < checkedIdStates.size(); checkedIndex++) { - final long id = checkedIdStates.keyAt(checkedIndex); - final int lastPos = checkedIdStates.valueAt(checkedIndex); - - if ((lastPos >= itemCount) || (id != adapter.getItemId(lastPos))) { - // Look around to see if the ID is nearby. If not, uncheck it. - final int start = Math.max(0, lastPos - CHECK_POSITION_SEARCH_DISTANCE); - final int end = Math.min(lastPos + CHECK_POSITION_SEARCH_DISTANCE, itemCount); - boolean found = false; - for (int searchPos = start; searchPos < end; searchPos++) { - final long searchId = adapter.getItemId(searchPos); - if (id == searchId) { - found = true; - checkStates.put(searchPos, true); - checkedIdStates.setValueAt(checkedIndex, searchPos); - break; - } - } - - if (!found) { - checkedIdStates.remove(id); - checkedIndex--; - checkedItemCount--; - checkedCountChanged = true; - if (choiceActionMode != null && multiChoiceModeCallback != null) { - multiChoiceModeCallback.onItemCheckedStateChanged(choiceActionMode, lastPos, id, false); - } - } - } else { - checkStates.put(lastPos, true); - } - } - } else { - // If the total number of items decreased, remove all out-of-range check indexes. - for (int i = checkStates.size() - 1; (i >= 0) && (checkStates.keyAt(i) >= itemCount); i--) { - if (checkStates.valueAt(i)) { - checkedItemCount--; - checkedCountChanged = true; - } - checkStates.delete(checkStates.keyAt(i)); - } - } - - if (checkedCountChanged && choiceActionMode != null) { - if (checkedItemCount == 0) { - choiceActionMode.finish(); - } else { - choiceActionMode.invalidate(); - } - } - } - - class AdapterDataSetObserver extends RecyclerView.AdapterDataObserver { - - @Override - public void onChanged() { - confirmCheckedPositions(); - } - - @Override - public void onItemRangeInserted(int positionStart, int itemCount) { - if (itemCount > 0) { - onAdapterPopulated(); - } - } - - @Override - public void onItemRangeMoved(int fromPosition, int toPosition, int itemCount) { - confirmCheckedPositions(); - } - - @Override - public void onItemRangeRemoved(int positionStart, int itemCount) { - confirmCheckedPositions(); - } - } - - class MultiChoiceModeWrapper implements MultiChoiceModeListener { - - private MultiChoiceModeListener wrapped; - - public void setWrapped(@NonNull MultiChoiceModeListener wrapped) { - this.wrapped = wrapped; - } - - @Override - public boolean onCreateActionMode(ActionMode mode, Menu menu) { - return wrapped.onCreateActionMode(mode, menu); - } - - @Override - public boolean onPrepareActionMode(ActionMode mode, Menu menu) { - return wrapped.onPrepareActionMode(mode, menu); - } - - @Override - public boolean onActionItemClicked(ActionMode mode, MenuItem item) { - return wrapped.onActionItemClicked(mode, item); - } - - @Override - public void onDestroyActionMode(ActionMode mode) { - wrapped.onDestroyActionMode(mode); - choiceActionMode = null; - clearChoices(); - } - - @Override - public void onItemCheckedStateChanged(ActionMode mode, int position, long id, boolean checked) { - wrapped.onItemCheckedStateChanged(mode, position, id, checked); - } - } -} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/widgets/MultiChoiceHelper.kt b/app/src/main/java/be/digitalia/fosdem/widgets/MultiChoiceHelper.kt new file mode 100644 index 0000000..51b9a5b --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/widgets/MultiChoiceHelper.kt @@ -0,0 +1,340 @@ +package be.digitalia.fosdem.widgets + +import android.os.Bundle +import android.os.Parcelable +import android.util.LongSparseArray +import android.util.SparseBooleanArray +import android.view.View +import android.widget.Checkable +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.view.ActionMode +import androidx.core.util.set +import androidx.core.util.size +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.AdapterDataObserver +import androidx.savedstate.SavedStateRegistryOwner +import be.digitalia.fosdem.utils.IntLongSparseArrayParceler +import kotlinx.android.parcel.Parcelize +import kotlinx.android.parcel.WriteWith + +/** + * Helper class to reproduce ListView's modal MultiChoice mode with a RecyclerView. + * Declare and use this class from inside your Adapter. + * + * @author Christophe Beyls + */ +class MultiChoiceHelper(private val activity: AppCompatActivity, + owner: SavedStateRegistryOwner, + private val adapter: RecyclerView.Adapter<*>) { + /** + * A handy ViewHolder base class which works with the MultiChoiceHelper + * and reproduces the default behavior of a ListView. + */ + abstract class ViewHolder(itemView: View, private val multiChoiceHelper: MultiChoiceHelper) : RecyclerView.ViewHolder(itemView) { + + private var clickListener: View.OnClickListener? = null + + fun setOnClickListener(clickListener: View.OnClickListener?) { + this.clickListener = clickListener + } + + fun bindSelection() { + val position = adapterPosition + if (position != RecyclerView.NO_POSITION) { + val isChecked = multiChoiceHelper.isItemChecked(position) + val mainView = itemView + if (mainView is Checkable) { + mainView.isChecked = isChecked + } else { + mainView.isActivated = isChecked + } + } + } + + val isMultiChoiceActive: Boolean + get() = multiChoiceHelper.checkedItemCount > 0 + + init { + itemView.setOnClickListener { view -> + if (isMultiChoiceActive) { + val position = adapterPosition + if (position != RecyclerView.NO_POSITION) { + multiChoiceHelper.toggleItemChecked(position) + } + } else { + clickListener?.onClick(view) + } + } + itemView.setOnLongClickListener { + if (isMultiChoiceActive) { + return@setOnLongClickListener false + } + val position = adapterPosition + if (position != RecyclerView.NO_POSITION) { + multiChoiceHelper.setItemChecked(position, true) + } + true + } + } + } + + interface MultiChoiceModeListener : ActionMode.Callback { + /** + * Called when an item is checked or unchecked during selection mode. + * + * @param mode The [ActionMode] providing the selection startSupportActionModemode + * @param position Adapter position of the item that was checked or unchecked + * @param id Adapter ID of the item that was checked or unchecked + * @param checked `true` if the item is now checked, `false` + * if the item is now unchecked. + */ + fun onItemCheckedStateChanged(mode: ActionMode, position: Int, id: Long, checked: Boolean) + } + + val checkedItemPositions: SparseBooleanArray + private val checkedIdStates: LongSparseArray? + var checkedItemCount: Int + private set + private var multiChoiceModeCallback: MultiChoiceModeWrapper? = null + private var choiceActionMode: ActionMode? = null + + /** + * Make sure this constructor is called before setting the adapter on the RecyclerView + * so this class will be notified before the RecyclerView in case of data set changes. + */ + init { + adapter.registerAdapterDataObserver(AdapterDataSetObserver()) + val restoreBundle = owner.savedStateRegistry.consumeRestoredStateForKey(STATE_KEY) + if (restoreBundle == null) { + checkedItemCount = 0 + checkedItemPositions = SparseBooleanArray(0) + checkedIdStates = if (adapter.hasStableIds()) LongSparseArray(0) else null + } else { + val savedState: SavedState = restoreBundle.getParcelable(PARCELABLE_KEY)!! + checkedItemCount = savedState.checkedItemCount + checkedItemPositions = savedState.checkedItemPositions + checkedIdStates = savedState.checkedIdStates + // Try early restoration, otherwise do it when items are inserted + if (adapter.itemCount > 0) { + onAdapterPopulated() + } + } + owner.savedStateRegistry.registerSavedStateProvider(STATE_KEY) { + Bundle(1).apply { + putParcelable(PARCELABLE_KEY, SavedState(checkedItemCount, checkedItemPositions.clone(), checkedIdStates?.clone())) + } + } + owner.lifecycle.addObserver(object : DefaultLifecycleObserver { + override fun onDestroy(owner: LifecycleOwner) { + clearChoices() + } + }) + } + + fun setMultiChoiceModeListener(listener: MultiChoiceModeListener?) { + multiChoiceModeCallback = if (listener == null) null else MultiChoiceModeWrapper(listener) + } + + fun isItemChecked(position: Int): Boolean { + return checkedItemPositions[position] + } + + val checkedItemIds: LongArray + get() { + val idStates = checkedIdStates ?: return LongArray(0) + return LongArray(idStates.size()) { idStates.keyAt(it) } + } + + fun clearChoices() { + if (checkedItemCount > 0) { + val start = checkedItemPositions.keyAt(0) + val end = checkedItemPositions.keyAt(checkedItemPositions.size() - 1) + checkedItemPositions.clear() + checkedIdStates?.clear() + checkedItemCount = 0 + + adapter.notifyItemRangeChanged(start, end - start + 1, SELECTION_PAYLOAD) + + choiceActionMode?.finish() + } + } + + fun setItemChecked(position: Int, value: Boolean) { + // Start selection mode if needed. We don't need it if we're unchecking something. + if (value) { + startSupportActionModeIfNeeded() + } + + val oldValue = checkedItemPositions[position] + checkedItemPositions[position] = value + + if (oldValue != value) { + val id = adapter.getItemId(position) + + if (checkedIdStates != null) { + if (value) { + checkedIdStates[id] = position + } else { + checkedIdStates.remove(id) + } + } + + if (value) { + checkedItemCount++ + } else { + checkedItemCount-- + } + + adapter.notifyItemChanged(position, SELECTION_PAYLOAD) + + val actionMode = choiceActionMode + if (actionMode != null) { + multiChoiceModeCallback?.onItemCheckedStateChanged(actionMode, position, id, value) + if (checkedItemCount == 0) { + actionMode.finish() + } + } + } + } + + fun toggleItemChecked(position: Int) { + setItemChecked(position, !isItemChecked(position)) + } + + private fun onAdapterPopulated() { + confirmCheckedPositions() + if (checkedItemCount > 0) { + startSupportActionModeIfNeeded() + } + } + + private fun startSupportActionModeIfNeeded() { + if (choiceActionMode == null) { + val callback = checkNotNull(multiChoiceModeCallback) { "No callback set" } + choiceActionMode = activity.startSupportActionMode(callback) + } + } + + fun confirmCheckedPositions() { + if (checkedItemCount == 0) { + return + } + + val itemCount = adapter.itemCount + var checkedCountChanged = false + + if (itemCount == 0) { + // Optimized path for empty adapter: remove all items. + checkedItemPositions.clear() + checkedIdStates?.clear() + checkedItemCount = 0 + checkedCountChanged = true + } else if (checkedIdStates != null) { + // Clear out the positional check states, we'll rebuild it below from IDs. + checkedItemPositions.clear() + + var checkedIndex = 0 + while (checkedIndex < checkedIdStates.size) { + val id = checkedIdStates.keyAt(checkedIndex) + val lastPos = checkedIdStates.valueAt(checkedIndex) + + if (lastPos >= itemCount || id != adapter.getItemId(lastPos)) { + // Look around to see if the ID is nearby. If not, uncheck it. + val start = (lastPos - CHECK_POSITION_SEARCH_DISTANCE).coerceAtLeast(0) + val end = (lastPos + CHECK_POSITION_SEARCH_DISTANCE).coerceAtMost(itemCount) + var found = false + for (searchPos in start until end) { + val searchId = adapter.getItemId(searchPos) + if (id == searchId) { + found = true + checkedItemPositions[searchPos] = true + checkedIdStates.setValueAt(checkedIndex, searchPos) + break + } + } + + if (!found) { + checkedIdStates.remove(id) + checkedIndex-- + checkedItemCount-- + checkedCountChanged = true + val actionMode = choiceActionMode + if (actionMode != null) { + multiChoiceModeCallback?.onItemCheckedStateChanged(actionMode, lastPos, id, false) + } + } + } else { + checkedItemPositions[lastPos] = true + } + checkedIndex++ + } + } else { + // If the total number of items decreased, remove all out-of-range check indexes. + for (i in checkedItemPositions.size - 1 downTo 0) { + val position = checkedItemPositions.keyAt(i) + if (position < itemCount) { + break + } + if (checkedItemPositions.valueAt(i)) { + checkedItemCount-- + checkedCountChanged = true + } + checkedItemPositions.delete(position) + } + } + + val actionMode = choiceActionMode + if (checkedCountChanged && actionMode != null) { + if (checkedItemCount == 0) { + actionMode.finish() + } else { + actionMode.invalidate() + } + } + } + + @Parcelize + class SavedState(val checkedItemCount: Int, + val checkedItemPositions: SparseBooleanArray, + val checkedIdStates: @WriteWith LongSparseArray?) : Parcelable + + private inner class AdapterDataSetObserver : AdapterDataObserver() { + override fun onChanged() { + confirmCheckedPositions() + } + + override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { + if (itemCount > 0) { + onAdapterPopulated() + } + } + + override fun onItemRangeMoved(fromPosition: Int, toPosition: Int, itemCount: Int) { + confirmCheckedPositions() + } + + override fun onItemRangeRemoved(positionStart: Int, itemCount: Int) { + confirmCheckedPositions() + } + } + + private inner class MultiChoiceModeWrapper(private val wrapped: MultiChoiceModeListener) : MultiChoiceModeListener by wrapped { + + override fun onDestroyActionMode(mode: ActionMode) { + wrapped.onDestroyActionMode(mode) + choiceActionMode = null + clearChoices() + } + } + + companion object { + @JvmField + val SELECTION_PAYLOAD = Any() + + private const val STATE_KEY = "MultiChoiceHelper" + private const val PARCELABLE_KEY = "saved_state" + private const val CHECK_POSITION_SEARCH_DISTANCE = 20 + } +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/widgets/PhotoViewDrawerLayout.java b/app/src/main/java/be/digitalia/fosdem/widgets/PhotoViewDrawerLayout.java deleted file mode 100644 index 369527e..0000000 --- a/app/src/main/java/be/digitalia/fosdem/widgets/PhotoViewDrawerLayout.java +++ /dev/null @@ -1,37 +0,0 @@ -package be.digitalia.fosdem.widgets; - -import android.content.Context; -import android.util.AttributeSet; -import android.view.MotionEvent; - -import androidx.drawerlayout.widget.DrawerLayout; - -/** - * DrawerLayout which includes a fix to prevent crashes with PhotoView. - *

- * See https://github.com/chrisbanes/PhotoView#issues-with-viewgroups - * http://code.google.com/p/android/issues/detail?id=18990 - */ -public class PhotoViewDrawerLayout extends DrawerLayout { - - public PhotoViewDrawerLayout(Context context) { - super(context); - } - - public PhotoViewDrawerLayout(Context context, AttributeSet attrs) { - super(context, attrs); - } - - public PhotoViewDrawerLayout(Context context, AttributeSet attrs, int defStyle) { - super(context, attrs, defStyle); - } - - @Override - public boolean onInterceptTouchEvent(MotionEvent ev) { - try { - return super.onInterceptTouchEvent(ev); - } catch (Exception e) { - return false; - } - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/widgets/PhotoViewDrawerLayout.kt b/app/src/main/java/be/digitalia/fosdem/widgets/PhotoViewDrawerLayout.kt new file mode 100644 index 0000000..b6fdb08 --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/widgets/PhotoViewDrawerLayout.kt @@ -0,0 +1,28 @@ +package be.digitalia.fosdem.widgets + +import android.content.Context +import android.util.AttributeSet +import android.view.MotionEvent +import androidx.drawerlayout.widget.DrawerLayout + +/** + * DrawerLayout which includes a fix to prevent crashes with PhotoView. + * + * + * See https://github.com/chrisbanes/PhotoView#issues-with-viewgroups + * http://code.google.com/p/android/issues/detail?id=18990 + */ +class PhotoViewDrawerLayout : DrawerLayout { + + constructor(context: Context) : super(context) + constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) + constructor(context: Context, attrs: AttributeSet?, defStyle: Int) : super(context, attrs, defStyle) + + override fun onInterceptTouchEvent(ev: MotionEvent): Boolean { + return try { + super.onInterceptTouchEvent(ev) + } catch (e: Exception) { + false + } + } +} \ No newline at end of file diff --git a/app/src/main/java/be/digitalia/fosdem/widgets/SaveStatePhotoView.java b/app/src/main/java/be/digitalia/fosdem/widgets/SaveStatePhotoView.java deleted file mode 100644 index 15c9ce4..0000000 --- a/app/src/main/java/be/digitalia/fosdem/widgets/SaveStatePhotoView.java +++ /dev/null @@ -1,103 +0,0 @@ -package be.digitalia.fosdem.widgets; - -import android.content.Context; -import android.graphics.RectF; -import android.os.Parcel; -import android.os.Parcelable; -import android.util.AttributeSet; -import android.view.ViewTreeObserver; -import com.github.chrisbanes.photoview.PhotoView; - -/** - * PhotoView which saves and restores the current scale and approximate position. - */ -public class SaveStatePhotoView extends PhotoView { - - public SaveStatePhotoView(Context context) { - super(context); - } - - public SaveStatePhotoView(Context context, AttributeSet attr) { - super(context, attr); - } - - public SaveStatePhotoView(Context context, AttributeSet attr, int defStyle) { - super(context, attr, defStyle); - } - - @Override - protected Parcelable onSaveInstanceState() { - Parcelable superState = super.onSaveInstanceState(); - SavedState ss = new SavedState(superState); - ss.scale = getScale(); - final RectF rect = getDisplayRect(); - final float overflowWidth = rect.width() - getWidth(); - if (overflowWidth > 0f) { - ss.pivotX = -rect.left / overflowWidth; - } - final float overflowHeight = rect.height() - getHeight(); - if (overflowHeight > 0f) { - ss.pivotY = -rect.top / overflowHeight; - } - return ss; - } - - @Override - protected void onRestoreInstanceState(Parcelable state) { - if (!(state instanceof SavedState)) { - super.onRestoreInstanceState(state); - return; - } - - final SavedState ss = (SavedState) state; - super.onRestoreInstanceState(ss.getSuperState()); - - getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() { - @Override - public void onGlobalLayout() { - float scale = Math.max(ss.scale, getMinimumScale()); - scale = Math.min(scale, getMaximumScale()); - setScale(scale, getWidth() * ss.pivotX, getHeight() * ss.pivotY, false); - getViewTreeObserver().removeOnGlobalLayoutListener(this); - } - }); - } - - public static class SavedState extends BaseSavedState { - float scale = 1f; - float pivotX = 0.5f; - float pivotY = 0.5f; - - public SavedState(Parcelable superState) { - super(superState); - } - - @Override - public void writeToParcel(Parcel out, int flags) { - super.writeToParcel(out, flags); - out.writeFloat(scale); - out.writeFloat(pivotX); - out.writeFloat(pivotY); - } - - public static final Parcelable.Creator CREATOR = new Parcelable.Creator() { - - @Override - public SavedState createFromParcel(Parcel in) { - return new SavedState(in); - } - - @Override - public SavedState[] newArray(int size) { - return new SavedState[size]; - } - }; - - SavedState(Parcel in) { - super(in); - scale = in.readFloat(); - pivotX = in.readFloat(); - pivotY = in.readFloat(); - } - } -} diff --git a/app/src/main/java/be/digitalia/fosdem/widgets/SaveStatePhotoView.kt b/app/src/main/java/be/digitalia/fosdem/widgets/SaveStatePhotoView.kt new file mode 100644 index 0000000..844571e --- /dev/null +++ b/app/src/main/java/be/digitalia/fosdem/widgets/SaveStatePhotoView.kt @@ -0,0 +1,87 @@ +package be.digitalia.fosdem.widgets + +import android.content.Context +import android.os.Parcel +import android.os.Parcelable +import android.util.AttributeSet +import androidx.core.view.doOnLayout +import androidx.customview.view.AbsSavedState +import com.github.chrisbanes.photoview.PhotoView + +/** + * PhotoView which saves and restores the current scale and approximate position. + */ +class SaveStatePhotoView : PhotoView { + + constructor(context: Context) : super(context) + constructor(context: Context, attr: AttributeSet?) : super(context, attr) + constructor(context: Context, attr: AttributeSet?, defStyle: Int) : super(context, attr, defStyle) + + override fun onSaveInstanceState(): Parcelable? { + val superState = super.onSaveInstanceState() + val rect = displayRect + val overflowWidth = rect.width() - width + val pivotX = if (overflowWidth > 0f) { + -rect.left / overflowWidth + } else 0.5f + val overflowHeight = rect.height() - height + val pivotY = if (overflowHeight > 0f) { + -rect.top / overflowHeight + } else 0.5f + return SavedState(superState ?: AbsSavedState.EMPTY_STATE, scale, pivotX, pivotY) + } + + override fun onRestoreInstanceState(state: Parcelable) { + if (state !is SavedState) { + super.onRestoreInstanceState(state) + return + } + + super.onRestoreInstanceState(state.superState) + + doOnLayout { + setScale(state.scale.coerceIn(minimumScale, maximumScale), + width * state.pivotX, + height * state.pivotY, + false) + } + } + + class SavedState : AbsSavedState { + + val scale: Float + val pivotX: Float + val pivotY: Float + + constructor(superState: Parcelable, scale: Float, pivotX: Float, pivotY: Float) : super(superState) { + this.scale = scale + this.pivotX = pivotX + this.pivotY = pivotY + } + + override fun writeToParcel(out: Parcel, flags: Int) { + super.writeToParcel(out, flags) + out.writeFloat(scale) + out.writeFloat(pivotX) + out.writeFloat(pivotY) + } + + private constructor(source: Parcel, loader: ClassLoader?) : super(source, loader) { + scale = source.readFloat() + pivotX = source.readFloat() + pivotY = source.readFloat() + } + + companion object { + @JvmField + @Suppress("UNUSED") + val CREATOR = object : Parcelable.ClassLoaderCreator { + override fun createFromParcel(source: Parcel, loader: ClassLoader?) = SavedState(source, loader) + + override fun createFromParcel(source: Parcel) = SavedState(source, null) + + override fun newArray(size: Int) = arrayOfNulls(size) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/res/layout/main.xml b/app/src/main/res/layout/main.xml index 8dbe4b9..0d0daac 100644 --- a/app/src/main/res/layout/main.xml +++ b/app/src/main/res/layout/main.xml @@ -56,7 +56,7 @@ app:menu="@menu/main_navigation">