music: change loading ux

Move the main loading response code to MainFragment and add a new
method for other objects to be notified of the progress of the
music loading process. There's probably a better way to do this,
but kotlin coroutines are so complex that I don't know where I
would start. This also adds some enhancements, such as the error
message now showing in more parts of the app and SearchFragment
now re-running the query if the MusicStore instance is loaded.
This commit is contained in:
OxygenCobalt 2021-10-27 18:23:15 -06:00
parent 255154c411
commit 51ba72d861
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
11 changed files with 243 additions and 153 deletions

View file

@ -18,15 +18,22 @@
package org.oxycblt.auxio
import android.Manifest
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.res.ResourcesCompat
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import com.google.android.material.snackbar.Snackbar
import org.oxycblt.auxio.databinding.FragmentMainBinding
import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.util.applyEdge
import org.oxycblt.auxio.util.applyMaterialDrawable
@ -38,6 +45,7 @@ import org.oxycblt.auxio.util.logD
*/
class MainFragment : Fragment() {
private val playbackModel: PlaybackViewModel by activityViewModels()
private val musicModel: MusicViewModel by activityViewModels()
override fun onCreateView(
inflater: LayoutInflater,
@ -46,6 +54,13 @@ class MainFragment : Fragment() {
): View {
val binding = FragmentMainBinding.inflate(inflater)
// Build the permission launcher here as you can only do it in onCreateView/onCreate
val permLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) {
musicModel.reloadMusic(requireContext())
}
// --- UI SETUP ---
binding.lifecycleOwner = viewLifecycleOwner
@ -58,6 +73,10 @@ class MainFragment : Fragment() {
// --- VIEWMODEL SETUP ---
// Initialize music loading. Unlike MainFragment, we can not only do this here on startup
// but also show a SnackBar in a reasonable place in this fragment.
musicModel.loadMusic(requireContext())
// Change CompactPlaybackFragment's visibility here so that an animation occurs.
binding.mainPlayback.isVisible = playbackModel.song.value != null
@ -65,6 +84,53 @@ class MainFragment : Fragment() {
binding.mainPlayback.isVisible = song != null
}
// Handle the music loader response.
musicModel.loaderResponse.observe(viewLifecycleOwner) { response ->
// Handle the loader response.
when (response) {
// OK, start restoring playback now
is MusicStore.Response.Ok -> playbackModel.setupPlayback(requireContext())
// Error, show the error to the user
is MusicStore.Response.Err -> {
logD("Received Error")
val errorRes = when (response.kind) {
MusicStore.ErrorKind.NO_MUSIC -> R.string.err_no_music
MusicStore.ErrorKind.NO_PERMS -> R.string.err_no_perms
MusicStore.ErrorKind.FAILED -> R.string.err_load_failed
}
val snackbar = Snackbar.make(
binding.root, getString(errorRes), Snackbar.LENGTH_INDEFINITE
)
snackbar.view.apply {
// Change the font family to our semibold color
findViewById<Button>(
com.google.android.material.R.id.snackbar_action
).typeface = ResourcesCompat.getFont(requireContext(), R.font.inter_semibold)
}
when (response.kind) {
MusicStore.ErrorKind.FAILED, MusicStore.ErrorKind.NO_MUSIC -> {
snackbar.setAction(R.string.lbl_retry) {
musicModel.reloadMusic(requireContext())
}
}
MusicStore.ErrorKind.NO_PERMS -> {
snackbar.setAction(R.string.lbl_grant) {
permLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE)
}
}
}
snackbar.show()
}
}
}
logD("Fragment Created.")
return binding.root

View file

@ -18,16 +18,12 @@
package org.oxycblt.auxio.home
import android.Manifest
import android.os.Bundle
import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import androidx.activity.result.contract.ActivityResultContracts
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.content.res.ResourcesCompat
import androidx.core.view.iterator
import androidx.core.view.updatePadding
import androidx.core.view.updatePaddingRelative
@ -38,7 +34,6 @@ import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.adapter.FragmentStateAdapter
import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.snackbar.Snackbar
import com.google.android.material.tabs.TabLayoutMediator
import org.oxycblt.auxio.MainFragmentDirections
import org.oxycblt.auxio.R
@ -52,6 +47,7 @@ import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.MusicViewModel
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.DisplayMode
@ -69,6 +65,7 @@ class HomeFragment : Fragment() {
private val playbackModel: PlaybackViewModel by activityViewModels()
private val detailModel: DetailViewModel by activityViewModels()
private val homeModel: HomeViewModel by activityViewModels()
private val musicModel: MusicViewModel by activityViewModels()
override fun onCreateView(
inflater: LayoutInflater,
@ -79,13 +76,6 @@ class HomeFragment : Fragment() {
var bottomPadding = 0
val sortItem: MenuItem
// Build the permission launcher here as you can only do it in onCreateView/onCreate
val permLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) {
homeModel.reloadMusic(requireContext())
}
// --- UI SETUP ---
binding.lifecycleOwner = viewLifecycleOwner
@ -218,84 +208,25 @@ class HomeFragment : Fragment() {
// --- VIEWMODEL SETUP ---
// Initialize music loading. Unlike MainFragment, we can not only do this here on startup
// but also show a SnackBar in a reasonable place in this fragment.
homeModel.loadMusic(requireContext())
// There is no way a fast scrolling event can continue across a re-create. Reset it.
homeModel.updateFastScrolling(false)
// TODO: We actually have to move this to MainFragment. This also means we have to
// have more than one thing watching the coroutine, which completely breaks what I
// wanted to do.
homeModel.loaderResponse.observe(viewLifecycleOwner) { response ->
musicModel.loaderResponse.observe(viewLifecycleOwner) { response ->
// Handle the loader response.
when (response) {
is MusicStore.Response.Ok -> {
logD("Received Ok")
binding.homeFab.show()
playbackModel.setupPlayback(requireContext())
}
is MusicStore.Response.Err -> {
logD("Received Error")
// We received an error. Hide the FAB and show a Snackbar with the error
// message and a corresponding action
binding.homeFab.hide()
val errorRes = when (response.kind) {
MusicStore.ErrorKind.NO_MUSIC -> R.string.err_no_music
MusicStore.ErrorKind.NO_PERMS -> R.string.err_no_perms
MusicStore.ErrorKind.FAILED -> R.string.err_load_failed
}
val snackbar = Snackbar.make(
binding.root, getString(errorRes), Snackbar.LENGTH_INDEFINITE
)
snackbar.view.apply {
// Change the font family to our semibold color
findViewById<Button>(
com.google.android.material.R.id.snackbar_action
).typeface = ResourcesCompat.getFont(requireContext(), R.font.inter_semibold)
fitsSystemWindows = false
// Prevent fitsSystemWindows margins from being applied to this view
// [We already do it]
setOnApplyWindowInsetsListener { v, insets -> insets }
}
when (response.kind) {
MusicStore.ErrorKind.FAILED, MusicStore.ErrorKind.NO_MUSIC -> {
snackbar.setAction(R.string.lbl_retry) {
homeModel.reloadMusic(requireContext())
}
}
MusicStore.ErrorKind.NO_PERMS -> {
snackbar.setAction(R.string.lbl_grant) {
permLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE)
}
}
}
snackbar.show()
}
is MusicStore.Response.Ok -> binding.homeFab.show()
// While loading or during an error, make sure we keep the shuffle fab hidden so
// that any kind of loading is impossible. PlaybackStateManager also relies on this
// invariant, so please don't change it.
null -> binding.homeFab.hide()
else -> binding.homeFab.hide()
}
}
homeModel.fastScrolling.observe(viewLifecycleOwner) { scrolling ->
// Make sure an update here doesn't mess up the FAB state when it comes to the
// loader response.
if (homeModel.loaderResponse.value !is MusicStore.Response.Ok) {
if (musicModel.loaderResponse.value !is MusicStore.Response.Ok) {
return@observe
}

View file

@ -18,12 +18,9 @@
package org.oxycblt.auxio.home
import android.content.Context
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre
@ -38,7 +35,7 @@ import org.oxycblt.auxio.ui.SortMode
* The ViewModel for managing [HomeFragment]'s data, sorting modes, and tab state.
* @author OxygenCobalt
*/
class HomeViewModel : ViewModel(), SettingsManager.Callback {
class HomeViewModel : ViewModel(), SettingsManager.Callback, MusicStore.MusicCallback {
private val settingsManager = SettingsManager.getInstance()
private val mSongs = MutableLiveData(listOf<Song>())
@ -74,48 +71,9 @@ class HomeViewModel : ViewModel(), SettingsManager.Callback {
private val mFastScrolling = MutableLiveData(false)
val fastScrolling: LiveData<Boolean> = mFastScrolling
private val mLoaderResponse = MutableLiveData<MusicStore.Response?>(null)
val loaderResponse: LiveData<MusicStore.Response?> = mLoaderResponse
private var isBusy = false
init {
settingsManager.addCallback(this)
}
/**
* Initiate the loading process. This is done here since HomeFragment will be the first
* fragment navigated to and because SnackBars will have the best UX here.
*/
fun loadMusic(context: Context) {
if (mLoaderResponse.value != null || isBusy) {
return
}
isBusy = true
mLoaderResponse.value = null
viewModelScope.launch {
val result = MusicStore.initInstance(context)
isBusy = false
mLoaderResponse.value = result
if (result is MusicStore.Response.Ok) {
val musicStore = result.musicStore
mSongs.value = settingsManager.libSongSort.sortSongs(musicStore.songs)
mAlbums.value = settingsManager.libAlbumSort.sortAlbums(musicStore.albums)
mArtists.value = settingsManager.libArtistSort.sortModels(musicStore.artists)
mGenres.value = settingsManager.libGenreSort.sortModels(musicStore.genres)
}
}
}
fun reloadMusic(context: Context) {
mLoaderResponse.value = null
loadMusic(context)
MusicStore.awaitInstance(this)
}
/**
@ -178,8 +136,16 @@ class HomeViewModel : ViewModel(), SettingsManager.Callback {
mRecreateTabs.value = true
}
override fun onLoaded(musicStore: MusicStore) {
mSongs.value = settingsManager.libSongSort.sortSongs(musicStore.songs)
mAlbums.value = settingsManager.libAlbumSort.sortAlbums(musicStore.albums)
mArtists.value = settingsManager.libArtistSort.sortModels(musicStore.artists)
mGenres.value = settingsManager.libGenreSort.sortModels(musicStore.genres)
}
override fun onCleared() {
super.onCleared()
settingsManager.removeCallback(this)
MusicStore.cancelAwaitInstance(this)
}
}

View file

@ -129,9 +129,14 @@ class MusicStore private constructor() {
NO_PERMS, NO_MUSIC, FAILED
}
interface MusicCallback {
fun onLoaded(musicStore: MusicStore)
}
companion object {
@Volatile
private var INSTANCE: Response? = null
private var RESPONSE: Response? = null
private val AWAITING = mutableListOf<MusicCallback>()
/**
* Initialize the loading process for this instance. This must be ran on a background
@ -139,21 +144,53 @@ class MusicStore private constructor() {
* immediately.
*/
suspend fun initInstance(context: Context): Response {
val currentInstance = INSTANCE
val currentInstance = RESPONSE
if (currentInstance is Response.Ok) {
return currentInstance
}
return withContext(Dispatchers.IO) {
val result = MusicStore().load(context)
val response = withContext(Dispatchers.IO) {
val response = MusicStore().load(context)
synchronized(this) {
INSTANCE = result
RESPONSE = response
}
result
response
}
if (response is Response.Ok) {
AWAITING.forEach { it.onLoaded(response.musicStore) }
AWAITING.clear()
}
return response
}
/**
* Await the successful creation of a [MusicStore] instance. The [callback]
* will be called if the instance is already loaded. It's recommended to call
* [cancelAwaitInstance] if the object is about to be destroyed to prevent any
* memory leaks.
*/
fun awaitInstance(callback: MusicCallback) {
// FIXME: There has to be some coroutiney way to do this instead of just making
// a leak-prone callback system
val currentInstance = maybeGetInstance()
if (currentInstance != null) {
callback.onLoaded(currentInstance)
}
AWAITING.add(callback)
}
/**
* Remove a callback from the queue.
*/
fun cancelAwaitInstance(callback: MusicCallback) {
AWAITING.remove(callback)
}
/**
@ -163,7 +200,7 @@ class MusicStore private constructor() {
* encountered an error. An instance is returned otherwise.
*/
fun maybeGetInstance(): MusicStore? {
val currentInstance = INSTANCE
val currentInstance = RESPONSE
return if (currentInstance is Response.Ok) {
currentInstance.musicStore

View file

@ -0,0 +1,59 @@
/*
* Copyright (c) 2021 Auxio Project
* MusicViewModel.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music
import android.content.Context
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch
class MusicViewModel : ViewModel() {
private val mLoaderResponse = MutableLiveData<MusicStore.Response?>(null)
val loaderResponse: LiveData<MusicStore.Response?> = mLoaderResponse
private var isBusy = false
/**
* Initiate the loading process. This is done here since HomeFragment will be the first
* fragment navigated to and because SnackBars will have the best UX here.
*/
fun loadMusic(context: Context) {
if (mLoaderResponse.value != null || isBusy) {
return
}
isBusy = true
mLoaderResponse.value = null
viewModelScope.launch {
val result = MusicStore.initInstance(context)
isBusy = false
mLoaderResponse.value = result
}
}
fun reloadMusic(context: Context) {
mLoaderResponse.value = null
loadMusic(context)
}
}

View file

@ -54,7 +54,10 @@ import org.oxycblt.auxio.util.logD
*/
class SearchFragment : Fragment() {
// SearchViewModel is only scoped to this Fragment
private val searchModel: SearchViewModel by viewModels()
private val searchModel: SearchViewModel by viewModels {
SearchViewModel.Factory(requireContext())
}
private val playbackModel: PlaybackViewModel by activityViewModels()
private val detailModel: DetailViewModel by activityViewModels()
@ -99,7 +102,7 @@ class SearchFragment : Fragment() {
setOnMenuItemClickListener { item ->
if (item.itemId != R.id.submenu_filtering) {
searchModel.updateFilterModeWithId(item.itemId, requireContext())
searchModel.updateFilterModeWithId(item.itemId)
item.isChecked = true
true
} else {
@ -111,7 +114,7 @@ class SearchFragment : Fragment() {
binding.searchEditText.apply {
addTextChangedListener { text ->
// Run the search with the updated text as the query
searchModel.doSearch(text?.toString() ?: "", requireContext())
searchModel.doSearch(text?.toString() ?: "")
}
// Auto-open the keyboard when this view is shown
@ -121,6 +124,7 @@ class SearchFragment : Fragment() {
imm.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT)
}
}
binding.searchRecycler.apply {
adapter = searchAdapter
@ -140,7 +144,6 @@ class SearchFragment : Fragment() {
}
if (results.isEmpty()) {
binding.searchAppbar.setExpanded(true)
binding.searchRecycler.visibility = View.INVISIBLE
} else {
binding.searchRecycler.visibility = View.VISIBLE

View file

@ -23,6 +23,7 @@ import androidx.annotation.IdRes
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.launch
import org.oxycblt.auxio.R
@ -37,7 +38,7 @@ import java.text.Normalizer
* The [ViewModel] for the search functionality
* @author OxygenCobalt
*/
class SearchViewModel : ViewModel() {
class SearchViewModel(context: Context) : ViewModel(), MusicStore.MusicCallback {
private val mSearchResults = MutableLiveData(listOf<BaseModel>())
private var mIsNavigating = false
private var mFilterMode: DisplayMode? = null
@ -50,15 +51,22 @@ class SearchViewModel : ViewModel() {
private val settingsManager = SettingsManager.getInstance()
private val songHeader = Header(id = -1, context.getString(R.string.lbl_songs))
private val albumHeader = Header(id = -1, context.getString(R.string.lbl_albums))
private val artistHeader = Header(id = -1, context.getString(R.string.lbl_artists))
private val genreHeader = Header(id = -1, context.getString(R.string.lbl_genres))
init {
mFilterMode = settingsManager.searchFilterMode
MusicStore.awaitInstance(this)
}
/**
* Use [query] to perform a search of the music library.
* Will push results to [searchResults].
*/
fun doSearch(query: String, context: Context) {
fun doSearch(query: String) {
val musicStore = MusicStore.maybeGetInstance()
mLastQuery = query
@ -75,28 +83,28 @@ class SearchViewModel : ViewModel() {
if (mFilterMode == null || mFilterMode == DisplayMode.SHOW_ARTISTS) {
musicStore.artists.filterByOrNull(query)?.let { artists ->
results.add(Header(id = -1, name = context.getString(R.string.lbl_artists)))
results.add(artistHeader)
results.addAll(artists)
}
}
if (mFilterMode == null || mFilterMode == DisplayMode.SHOW_ALBUMS) {
musicStore.albums.filterByOrNull(query)?.let { albums ->
results.add(Header(id = -2, name = context.getString(R.string.lbl_albums)))
results.add(albumHeader)
results.addAll(albums)
}
}
if (mFilterMode == null || mFilterMode == DisplayMode.SHOW_GENRES) {
musicStore.genres.filterByOrNull(query)?.let { genres ->
results.add(Header(id = -3, name = context.getString(R.string.lbl_genres)))
results.add(genreHeader)
results.addAll(genres)
}
}
if (mFilterMode == null || mFilterMode == DisplayMode.SHOW_SONGS) {
musicStore.songs.filterByOrNull(query)?.let { songs ->
results.add(Header(id = -4, name = context.getString(R.string.lbl_songs)))
results.add(songHeader)
results.addAll(songs)
}
}
@ -109,7 +117,7 @@ class SearchViewModel : ViewModel() {
* Update the current filter mode with a menu [id].
* New value will be pushed to [filterMode].
*/
fun updateFilterModeWithId(@IdRes id: Int, context: Context) {
fun updateFilterModeWithId(@IdRes id: Int) {
mFilterMode = when (id) {
R.id.option_filter_songs -> DisplayMode.SHOW_SONGS
R.id.option_filter_albums -> DisplayMode.SHOW_ALBUMS
@ -121,7 +129,7 @@ class SearchViewModel : ViewModel() {
settingsManager.searchFilterMode = mFilterMode
doSearch(mLastQuery, context)
doSearch(mLastQuery)
}
/**
@ -159,7 +167,8 @@ class SearchViewModel : ViewModel() {
idx += Character.charCount(cp)
when (Character.getType(cp)) {
// Character.NON_SPACING_MARK and Character.COMBINING_SPACING_MARK
// Character.NON_SPACING_MARK and Character.COMBINING_SPACING_MARK were added
// by normalizer
6, 8 -> continue
else -> sb.appendCodePoint(cp)
@ -175,4 +184,26 @@ class SearchViewModel : ViewModel() {
fun setNavigating(isNavigating: Boolean) {
mIsNavigating = isNavigating
}
// --- OVERRIDES ---
override fun onLoaded(musicStore: MusicStore) {
doSearch(mLastQuery)
}
override fun onCleared() {
super.onCleared()
MusicStore.cancelAwaitInstance(this)
}
class Factory(private val context: Context) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
check(modelClass.isAssignableFrom(SearchViewModel::class.java)) {
"SearchViewModel.Factory does not support this class"
}
@Suppress("UNCHECKED_CAST")
return SearchViewModel(context) as T
}
}
}

View file

@ -36,7 +36,6 @@ import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentAboutBinding
import org.oxycblt.auxio.home.HomeViewModel
import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.util.applyEdge
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.showToast
@ -69,14 +68,9 @@ class AboutFragment : Fragment() {
binding.aboutFaq.setOnClickListener { openLinkInBrowser(LINK_FAQ) }
binding.aboutLicenses.setOnClickListener { openLinkInBrowser(LINK_LICENSES) }
homeModel.loaderResponse.observe(viewLifecycleOwner) { response ->
val count = when (response) {
is MusicStore.Response.Ok -> response.musicStore.songs.size
else -> 0
}
homeModel.songs.observe(viewLifecycleOwner) { songs ->
binding.aboutSongCount.text = getString(
R.string.fmt_songs_loaded, count
R.string.fmt_songs_loaded, songs.size
)
}

View file

@ -81,9 +81,9 @@ class WidgetProvider : AppWidgetProvider() {
// Map each widget form to the cells where it would look at least okay.
val views = mapOf(
SizeF(180f, 152f) to createMinimalWidget(context, state),
SizeF(250f, 152f) to createCompactWidget(context, state),
SizeF(272f, 152f) to createCompactWidget(context, state),
SizeF(180f, 270f) to createSmallWidget(context, state),
SizeF(250f, 270f) to createFullWidget(context, state)
SizeF(272f, 270f) to createFullWidget(context, state)
)
appWidgetManager.applyViewsCompat(context, views)

View file

@ -2,11 +2,14 @@
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/info_widget_desc"
android:initialLayout="@layout/widget_small"
android:minResizeWidth="180dp"
android:minResizeWidth="176dp"
android:minResizeHeight="152dp"
android:previewLayout="@layout/widget_small"
android:previewImage="@drawable/ui_widget_preview"
android:resizeMode="horizontal|vertical"
android:minWidth="180dp"
android:minWidth="176dp"
android:minHeight="180dp"
android:targetCellWidth="3"
android:targetCellHeight="3"
android:updatePeriodMillis="0"
android:widgetCategory="home_screen" />

View file

@ -1,9 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:initialLayout="@layout/widget_small"
android:minWidth="180dp"
android:minWidth="176dp"
android:minHeight="180dp"
android:minResizeWidth="180dp"
android:minResizeWidth="176dp"
android:minResizeHeight="152dp"
android:previewImage="@drawable/ui_widget_preview"
android:resizeMode="horizontal|vertical"