diff --git a/REUSE.toml b/REUSE.toml index c8cc20b8a..bac23a37a 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -6,7 +6,7 @@ SPDX-PackageSupplier = "Nextcloud Android team " SPDX-PackageDownloadLocation = "https://github.com/nextcloud/notes-android" [[annotations]] -path = ["app/schemas/it.niedermann.owncloud.notes.persistence.NotesDatabase/**.json", "app/src/main/res/values-**/strings.xml", "renovate.json5", "**/.gitignore", ".idea/**", "scripts/analysis/findbugs-results.txt", "scripts/analysis/lint-results.txt", "app/src/test/resources/robolectric.properties", "app/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker", "fastlane/metadata/**", "app/src/main/res/mipmap-**dpi/ic_launcher.png", "app/src/main/ic_launcher-web.png", ".bundle/config", "app/src/main/res/drawable-**dpi/ic_widget_create.png", "app/src/main/res/drawable-mdpi/context_based_formatting.png", "app/src/main/res/drawable/note_list_widget_preview.webp", "app/src/main/res/drawable/single_note_widget.webp", "app/src/qa/res/drawable/ic_launcher_foreground.xml"] +path = ["app/schemas/it.niedermann.owncloud.notes.persistence.NotesDatabase/**.json", "app/src/main/res/values-**/strings.xml", "renovate.json5", "**/.gitignore", ".idea/**", "scripts/analysis/findbugs-results.txt", "scripts/analysis/lint-results.txt", "app/src/test/resources/robolectric.properties", "app/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker", "fastlane/metadata/**", "app/src/main/res/mipmap-**dpi/ic_launcher.png", "app/src/main/ic_launcher-web.png", ".bundle/config", "app/src/main/res/drawable-**dpi/ic_widget_create.png", "app/src/main/res/drawable-mdpi/context_based_formatting.png", "app/src/main/res/drawable/note_list_widget_preview.webp", "app/src/main/res/drawable/single_note_widget.webp", "app/src/main/res/drawable/interactive_note_list_widget_preview.webp", "app/src/qa/res/drawable/ic_launcher_foreground.xml"] precedence = "aggregate" SPDX-FileCopyrightText = "2016-2025 Nextcloud GmbH and Nextcloud contributors" SPDX-License-Identifier = "GPL-3.0-or-later" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 111a0dc01..3b1cd56f6 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -159,6 +159,14 @@ + + + + + + + + + + + + + + + + + + { updateSingleNoteWidgets(context); updateNoteListWidgets(context); + updateInteractiveNoteListWidgets(context); }); } diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/dao/WidgetNotesListDao.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/dao/WidgetNotesListDao.java index 1f34e9278..435c1fd36 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/persistence/dao/WidgetNotesListDao.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/dao/WidgetNotesListDao.java @@ -8,6 +8,7 @@ import androidx.room.Dao; import androidx.room.Insert; +import androidx.room.OnConflictStrategy; import androidx.room.Query; import it.niedermann.owncloud.notes.persistence.entity.NotesListWidgetData; @@ -15,7 +16,7 @@ @Dao public interface WidgetNotesListDao { - @Insert + @Insert(onConflict = OnConflictStrategy.REPLACE) void createOrUpdateNoteListWidgetData(NotesListWidgetData data); @Query("DELETE FROM NOTESLISTWIDGETDATA WHERE id = :appWidgetId") diff --git a/app/src/main/java/it/niedermann/owncloud/notes/widget/interactivelist/InteractiveNoteListWidget.kt b/app/src/main/java/it/niedermann/owncloud/notes/widget/interactivelist/InteractiveNoteListWidget.kt new file mode 100644 index 000000000..136864783 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/widget/interactivelist/InteractiveNoteListWidget.kt @@ -0,0 +1,176 @@ +/* + * Nextcloud Notes - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package it.niedermann.owncloud.notes.widget.interactivelist + +import android.app.PendingIntent +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProvider +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.res.ColorStateList +import android.os.Build +import android.os.Bundle +import android.util.Log +import android.widget.RemoteViews +import androidx.core.net.toUri +import it.niedermann.owncloud.notes.R +import it.niedermann.owncloud.notes.branding.BrandingUtil +import it.niedermann.owncloud.notes.edit.EditNoteActivity +import it.niedermann.owncloud.notes.persistence.NotesRepository +import it.niedermann.owncloud.notes.persistence.entity.NotesListWidgetData +import it.niedermann.owncloud.notes.shared.model.ENavigationCategoryType +import it.niedermann.owncloud.notes.shared.model.NavigationCategory +import it.niedermann.owncloud.notes.shared.util.WidgetUtil +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors + +class InteractiveNoteListWidget : AppWidgetProvider() { + private val executor: ExecutorService = Executors.newCachedThreadPool() + + override fun onUpdate( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetIds: IntArray + ) { + super.onUpdate(context, appWidgetManager, appWidgetIds) + updateAppWidget(context, appWidgetManager, appWidgetIds) + } + + override fun onReceive(context: Context, intent: Intent) { + super.onReceive(context, intent) + val awm = AppWidgetManager.getInstance(context) + + if (intent.action == null) { + Log.w(TAG, "Intent action is null") + return + } + + if (intent.action != AppWidgetManager.ACTION_APPWIDGET_UPDATE) { + Log.w(TAG, "Intent action is not ACTION_APPWIDGET_UPDATE") + return + } + + val appWidgetIds = if (intent.hasExtra(AppWidgetManager.EXTRA_APPWIDGET_ID)) { + intArrayOf( + intent.getIntExtra( + AppWidgetManager.EXTRA_APPWIDGET_ID, + AppWidgetManager.INVALID_APPWIDGET_ID + ) + ) + } else { + awm.getAppWidgetIds(ComponentName(context, InteractiveNoteListWidget::class.java)) + } + + updateAppWidget(context, awm, appWidgetIds) + } + + override fun onDeleted(context: Context, appWidgetIds: IntArray) { + super.onDeleted(context, appWidgetIds) + val repo = NotesRepository.getInstance(context) + + for (appWidgetId in appWidgetIds) { + executor.execute { + repo.removeNoteListWidget(appWidgetId) + InteractiveWidgetPreferences.remove(context, appWidgetId) + } + } + } + + companion object { + private val TAG: String = InteractiveNoteListWidget::class.java.simpleName + + fun updateAppWidget(context: Context, awm: AppWidgetManager, appWidgetIds: IntArray) { + val repo = NotesRepository.getInstance(context) + appWidgetIds.forEach { appWidgetId -> + val data = repo.getNoteListWidgetData(appWidgetId) ?: return@forEach + updateSingleWidget(context, awm, appWidgetId, data) + } + } + + private fun updateSingleWidget( + context: Context, + awm: AppWidgetManager, + appWidgetId: Int, + data: NotesListWidgetData + ) { + val serviceIntent = Intent(context, InteractiveNoteListWidgetService::class.java).apply { + putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) + setData(toUri(Intent.URI_INTENT_SCHEME).toUri()) + } + + val openTemplateIntent = Intent(context, EditNoteActivity::class.java).apply { + setPackage(context.packageName) + } + val openTemplateFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE + } else { + PendingIntent.FLAG_UPDATE_CURRENT + } + val openTemplatePendingIntent = + PendingIntent.getActivity(context, 0, openTemplateIntent, openTemplateFlags) + + val createNotePendingIntent = + PendingIntent.getActivity( + context, + appWidgetId, + createNoteIntent(context, data), + WidgetUtil.pendingIntentFlagCompat(PendingIntent.FLAG_UPDATE_CURRENT) + ) + + val views = RemoteViews(context.packageName, R.layout.widget_interactive_note_list).apply { + setRemoteAdapter(R.id.interactive_note_list_lv, serviceIntent) + setPendingIntentTemplate(R.id.interactive_note_list_lv, openTemplatePendingIntent) + setEmptyView( + R.id.interactive_note_list_lv, + R.id.interactive_note_list_placeholder_tv + ) + setOnClickPendingIntent(R.id.interactive_create_note_button, createNotePendingIntent) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + setColorStateList( + R.id.interactive_create_note_button, + "setBackgroundTintList", + ColorStateList.valueOf(BrandingUtil.readBrandMainColor(context)) + ) + } + } + + awm.run { + updateAppWidget(appWidgetId, views) + notifyAppWidgetViewDataChanged(intArrayOf(appWidgetId), R.id.interactive_note_list_lv) + } + } + + private fun createNoteIntent(context: Context, data: NotesListWidgetData): Intent { + val navigationCategory = if (data.mode == NotesListWidgetData.MODE_DISPLAY_STARRED) { + NavigationCategory(ENavigationCategoryType.FAVORITES) + } else { + NavigationCategory(data.accountId, data.category) + } + + val bundle = Bundle().apply { + putSerializable(EditNoteActivity.PARAM_CATEGORY, navigationCategory) + putLong(EditNoteActivity.PARAM_ACCOUNT_ID, data.accountId) + } + + return Intent(context, EditNoteActivity::class.java).apply { + setPackage(context.packageName) + putExtras(bundle) + setData("interactive-create://${data.id}".toUri()) + } + } + + @JvmStatic + fun updateInteractiveNoteListWidgets(context: Context) { + val intent = Intent(context, InteractiveNoteListWidget::class.java).apply { + action = AppWidgetManager.ACTION_APPWIDGET_UPDATE + } + context.sendBroadcast(intent) + } + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/widget/interactivelist/InteractiveNoteListWidgetConfigurationActivity.kt b/app/src/main/java/it/niedermann/owncloud/notes/widget/interactivelist/InteractiveNoteListWidgetConfigurationActivity.kt new file mode 100644 index 000000000..39b0df467 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/widget/interactivelist/InteractiveNoteListWidgetConfigurationActivity.kt @@ -0,0 +1,226 @@ +/* + * Nextcloud Notes - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package it.niedermann.owncloud.notes.widget.interactivelist + +import android.app.Activity +import android.appwidget.AppWidgetManager +import android.content.Intent +import android.os.Bundle +import android.util.Log +import android.widget.Toast +import androidx.lifecycle.ViewModelProvider +import com.nextcloud.android.sso.exceptions.SSOException +import com.nextcloud.android.sso.helper.SingleAccountHelper +import it.niedermann.owncloud.notes.LockedActivity +import it.niedermann.owncloud.notes.NotesApplication +import it.niedermann.owncloud.notes.R +import it.niedermann.owncloud.notes.branding.BrandingUtil +import it.niedermann.owncloud.notes.databinding.ActivityInteractiveWidgetConfigurationBinding +import it.niedermann.owncloud.notes.main.MainActivity +import it.niedermann.owncloud.notes.main.navigation.NavigationAdapter +import it.niedermann.owncloud.notes.main.navigation.NavigationClickListener +import it.niedermann.owncloud.notes.main.navigation.NavigationItem +import it.niedermann.owncloud.notes.persistence.NotesRepository +import it.niedermann.owncloud.notes.persistence.entity.Account +import it.niedermann.owncloud.notes.persistence.entity.NotesListWidgetData +import it.niedermann.owncloud.notes.shared.model.ENavigationCategoryType +import it.niedermann.owncloud.notes.widget.notelist.NoteListViewModel +import java.util.concurrent.Executors + +class InteractiveNoteListWidgetConfigurationActivity : LockedActivity(), NavigationClickListener { + private val executor = Executors.newCachedThreadPool() + + private var binding: ActivityInteractiveWidgetConfigurationBinding? = null + private lateinit var viewModel: NoteListViewModel + private lateinit var adapterCategories: NavigationAdapter + private lateinit var repo: NotesRepository + + private var appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID + private var localAccount: Account? = null + private var selectedItemId = MainActivity.ADAPTER_KEY_RECENT + private var selectedItem: NavigationItem? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setResult(Activity.RESULT_CANCELED) + + repo = NotesRepository.getInstance(this) + appWidgetId = intent.extras?.getInt( + AppWidgetManager.EXTRA_APPWIDGET_ID, + AppWidgetManager.INVALID_APPWIDGET_ID + ) ?: AppWidgetManager.INVALID_APPWIDGET_ID + + if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) { + Log.d(TAG, "INVALID_APPWIDGET_ID") + finish() + return + } + + viewModel = ViewModelProvider(this)[NoteListViewModel::class.java] + adapterCategories = NavigationAdapter(this, this) + + val views = ActivityInteractiveWidgetConfigurationBinding.inflate(layoutInflater) + binding = views + setContentView(views.root) + views.recyclerView.adapter = adapterCategories + views.saveWidget.setOnClickListener { saveWidget() } + restorePreferences(views) + + loadAccount() + } + + private fun restorePreferences(views: ActivityInteractiveWidgetConfigurationBinding) { + views.favoritesFirstCheckbox.isChecked = + InteractiveWidgetPreferences.isFavoritesFirst(this, appWidgetId) + + val sortOrder = InteractiveWidgetPreferences.getSortOrder(this, appWidgetId) + if (sortOrder == WidgetSortOrder.OLDEST_FIRST) { + views.sortOldestFirst.isChecked = true + } else { + views.sortNewestFirst.isChecked = true + } + } + + override fun onItemClick(item: NavigationItem) = select(item) + + override fun onIconClick(item: NavigationItem) = select(item) + + private fun applyNavigationItems(navigationItems: List) { + adapterCategories.setItems(navigationItems) + + val item = navigationItems.firstOrNull { it.id == selectedItemId } + ?: navigationItems.firstOrNull { it.id == MainActivity.ADAPTER_KEY_RECENT } + ?: return + + select(item) + } + + private fun select(item: NavigationItem) { + selectedItem = item + selectedItemId = item.id + adapterCategories.setSelectedItem(item.id) + } + + private fun saveWidget() { + val account = localAccount ?: run { + Log.w(TAG, "No account loaded; ignoring selection") + return + } + val item = selectedItem ?: run { + Log.w(TAG, "No category selected; ignoring selection") + return + } + val views = binding ?: return + + val favoritesFirst = views.favoritesFirstCheckbox.isChecked + val sortOrder = if (views.sortOldestFirst.isChecked) { + WidgetSortOrder.OLDEST_FIRST + } else { + WidgetSortOrder.NEWEST_FIRST + } + + val data = NotesListWidgetData().apply { + id = appWidgetId + accountId = account.id + themeMode = NotesApplication.getAppTheme(applicationContext).modeId + } + applyNavigationMode(data, item) + + executor.execute { + repo.createOrUpdateNoteListWidgetData(data) + InteractiveWidgetPreferences.save(applicationContext, appWidgetId, favoritesFirst, sortOrder) + + val updateIntent = Intent( + AppWidgetManager.ACTION_APPWIDGET_UPDATE, + null, + applicationContext, + InteractiveNoteListWidget::class.java + ).putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) + + applicationContext.sendBroadcast(updateIntent) + + runOnUiThread { + setResult(Activity.RESULT_OK, updateIntent) + finish() + } + } + } + + private fun applyNavigationMode(data: NotesListWidgetData, item: NavigationItem) { + when (item.type) { + ENavigationCategoryType.RECENT -> data.mode = NotesListWidgetData.MODE_DISPLAY_ALL + ENavigationCategoryType.FAVORITES -> data.mode = NotesListWidgetData.MODE_DISPLAY_STARRED + ENavigationCategoryType.UNCATEGORIZED -> { + data.mode = NotesListWidgetData.MODE_DISPLAY_CATEGORY + data.category = null + } + + ENavigationCategoryType.DEFAULT_CATEGORY -> applyCategoryMode(data, item) + null -> fallBackToAllNotes(data) + } + } + + private fun applyCategoryMode(data: NotesListWidgetData, item: NavigationItem) { + if (item !is NavigationItem.CategoryNavigationItem) { + fallBackToAllNotes(data) + return + } + + data.mode = NotesListWidgetData.MODE_DISPLAY_CATEGORY + data.category = item.category + } + + private fun fallBackToAllNotes(data: NotesListWidgetData) { + data.mode = NotesListWidgetData.MODE_DISPLAY_ALL + Log.e(TAG, "Unknown item navigation type. Fallback to show ${ENavigationCategoryType.RECENT}") + } + + private fun loadAccount() { + executor.execute { + val account = currentAccount() + if (account == null) { + runOnUiThread { + Toast.makeText(this, R.string.widget_not_logged_in, Toast.LENGTH_LONG).show() + finish() + } + return@execute + } + + localAccount = account + val storedData = repo.getNoteListWidgetData(appWidgetId) + selectedItemId = InteractiveWidgetSelection.navigationItemId(storedData) + + runOnUiThread { + if (storedData != null) { + binding?.saveWidget?.setText(R.string.widget_edit) + } + viewModel.getAdapterCategories(account.id).observe(this, ::applyNavigationItems) + } + } + } + + private fun currentAccount(): Account? = try { + repo.getAccountByName(SingleAccountHelper.getCurrentSingleSignOnAccount(this).name) + } catch (e: SSOException) { + Log.w(TAG, "Account not found", e) + null + } + + override fun applyBrand(color: Int) { + val views = binding ?: return + val util = BrandingUtil.of(color, this) + + util.platform.themeCheckbox(views.favoritesFirstCheckbox) + util.platform.themeRadioButton(views.sortNewestFirst) + util.platform.themeRadioButton(views.sortOldestFirst) + util.material.themeExtendedFAB(views.saveWidget) + } + + companion object { + private val TAG: String = InteractiveNoteListWidgetConfigurationActivity::class.java.simpleName + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/widget/interactivelist/InteractiveNoteListWidgetFactory.kt b/app/src/main/java/it/niedermann/owncloud/notes/widget/interactivelist/InteractiveNoteListWidgetFactory.kt new file mode 100644 index 000000000..502714cc2 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/widget/interactivelist/InteractiveNoteListWidgetFactory.kt @@ -0,0 +1,182 @@ +/* + * Nextcloud Notes - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package it.niedermann.owncloud.notes.widget.interactivelist + +import android.appwidget.AppWidgetManager +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.util.Log +import android.view.View +import android.widget.RemoteViews +import android.widget.RemoteViewsService.RemoteViewsFactory +import androidx.core.content.ContextCompat +import androidx.core.net.toUri +import com.nextcloud.android.common.ui.util.PlatformThemeUtil +import it.niedermann.owncloud.notes.R +import it.niedermann.owncloud.notes.edit.EditNoteActivity +import it.niedermann.owncloud.notes.persistence.NotesRepository +import it.niedermann.owncloud.notes.persistence.entity.Note +import it.niedermann.owncloud.notes.persistence.entity.NotesListWidgetData +import it.niedermann.owncloud.notes.shared.util.NoteUtil + +class InteractiveNoteListWidgetFactory internal constructor(private val context: Context, intent: Intent) : + RemoteViewsFactory { + private val appWidgetId: Int = intent.getIntExtra( + AppWidgetManager.EXTRA_APPWIDGET_ID, + AppWidgetManager.INVALID_APPWIDGET_ID + ) + private val repo: NotesRepository = NotesRepository.getInstance(context) + private val dbNotes: MutableList = ArrayList() + + override fun onCreate() = Unit + + override fun onDataSetChanged() { + dbNotes.clear() + try { + val widgetData = repo.getNoteListWidgetData(appWidgetId) + if (widgetData == null) { + Log.w(TAG, "Widget data is null") + return + } + + when (widgetData.mode) { + NotesListWidgetData.MODE_DISPLAY_ALL -> dbNotes.addAll( + repo.searchRecentByModified( + widgetData.accountId, "%" + ) + ) + + NotesListWidgetData.MODE_DISPLAY_STARRED -> dbNotes.addAll( + repo.searchFavoritesByModified( + widgetData.accountId, "%" + ) + ) + + else -> { + if (widgetData.category != null) { + dbNotes.addAll( + repo.searchCategoryByModified( + widgetData.accountId, + "%", + widgetData.category + ) + ) + } else { + dbNotes.addAll( + repo.searchUncategorizedByModified( + widgetData.accountId, + "%" + ) + ) + } + } + } + + applySorting(context, appWidgetId, dbNotes) + } catch (e: Exception) { + Log.w(TAG, "Error caught at onDataSetChanged", e) + } + } + + override fun onDestroy() = Unit + + override fun getCount(): Int { + return dbNotes.size + } + + override fun getViewAt(position: Int): RemoteViews? { + val note = dbNotes.getOrNull(position) ?: return null + + return RemoteViews(context.packageName, R.layout.widget_interactive_entry).apply { + setOnClickFillInIntent(R.id.interactive_entry, getOpenNoteIntent(context, note)) + + setTextViewText(R.id.interactive_entry_title, note.title) + + if (note.excerpt.isEmpty()) { + setViewVisibility(R.id.interactive_entry_excerpt, View.GONE) + } else { + setViewVisibility(R.id.interactive_entry_excerpt, View.VISIBLE) + setTextViewText( + R.id.interactive_entry_excerpt, + note.excerpt.replace(NoteUtil.EXCERPT_LINE_SEPARATOR, "\n") + ) + } + + if (note.category.isEmpty()) { + setViewVisibility(R.id.interactive_entry_category, View.GONE) + } else { + setViewVisibility(R.id.interactive_entry_category, View.VISIBLE) + setTextViewText(R.id.interactive_entry_category, note.category) + + val textColorId = if (PlatformThemeUtil.isDarkMode(context)) { + R.color.text_color + } else { + R.color.category_border + } + val textColor = ContextCompat.getColor(context, textColorId) + setTextColor(R.id.interactive_entry_category, textColor) + } + + if (note.favorite) { + setViewVisibility(R.id.interactive_entry_fav_icon, View.VISIBLE) + setImageViewResource(R.id.interactive_entry_fav_icon, R.drawable.ic_star_yellow_24dp) + } else { + setViewVisibility(R.id.interactive_entry_fav_icon, View.GONE) + } + } + } + + override fun getLoadingView(): RemoteViews? { + return null + } + + override fun getViewTypeCount(): Int { + return 1 + } + + override fun getItemId(position: Int): Long { + return dbNotes[position].id + } + + override fun hasStableIds(): Boolean { + return true + } + + companion object { + private val TAG: String = InteractiveNoteListWidgetFactory::class.java.simpleName + } +} + +private fun applySorting(context: Context, appWidgetId: Int, notes: MutableList) { + val sortOrder = InteractiveWidgetPreferences.getSortOrder(context, appWidgetId) + val byDate: Comparator = if (sortOrder == WidgetSortOrder.OLDEST_FIRST) { + compareBy { it.modified?.timeInMillis ?: 0L } + } else { + compareByDescending { it.modified?.timeInMillis ?: 0L } + } + + if (!InteractiveWidgetPreferences.isFavoritesFirst(context, appWidgetId)) { + notes.sortWith(byDate) + return + } + + notes.sortWith(compareByDescending { it.favorite }.then(byDate)) +} + +private fun getOpenNoteIntent(context: Context, note: Note): Intent { + val bundle = Bundle().apply { + putLong(EditNoteActivity.PARAM_NOTE_ID, note.id) + putLong(EditNoteActivity.PARAM_ACCOUNT_ID, note.accountId) + } + + return Intent(context, EditNoteActivity::class.java).apply { + setPackage(context.packageName) + putExtras(bundle) + data = toUri(Intent.URI_INTENT_SCHEME).toUri() + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/widget/interactivelist/InteractiveNoteListWidgetService.kt b/app/src/main/java/it/niedermann/owncloud/notes/widget/interactivelist/InteractiveNoteListWidgetService.kt new file mode 100644 index 000000000..bc5bb7954 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/widget/interactivelist/InteractiveNoteListWidgetService.kt @@ -0,0 +1,15 @@ +/* + * Nextcloud Notes - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package it.niedermann.owncloud.notes.widget.interactivelist + +import android.content.Intent +import android.widget.RemoteViewsService + +class InteractiveNoteListWidgetService : RemoteViewsService() { + override fun onGetViewFactory(intent: Intent): RemoteViewsFactory = + InteractiveNoteListWidgetFactory(applicationContext, intent) +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/widget/interactivelist/InteractiveWidgetPreferences.kt b/app/src/main/java/it/niedermann/owncloud/notes/widget/interactivelist/InteractiveWidgetPreferences.kt new file mode 100644 index 000000000..12c245fea --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/widget/interactivelist/InteractiveWidgetPreferences.kt @@ -0,0 +1,44 @@ +/* + * Nextcloud Notes - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package it.niedermann.owncloud.notes.widget.interactivelist + +import android.content.Context +import androidx.core.content.edit + +object InteractiveWidgetPreferences { + private const val PREFS_NAME = "interactive_note_list_widget" + private const val KEY_FAVORITES_FIRST = "favorites_first_" + private const val KEY_SORT_ORDER = "sort_order_" + + private const val DEFAULT_FAVORITES_FIRST = true + + fun save(context: Context, appWidgetId: Int, favoritesFirst: Boolean, sortOrder: WidgetSortOrder) { + prefs(context).edit { + putBoolean(KEY_FAVORITES_FIRST + appWidgetId, favoritesFirst) + putString(KEY_SORT_ORDER + appWidgetId, sortOrder.name) + } + } + + fun isFavoritesFirst(context: Context, appWidgetId: Int): Boolean = + prefs(context).getBoolean(KEY_FAVORITES_FIRST + appWidgetId, DEFAULT_FAVORITES_FIRST) + + fun getSortOrder(context: Context, appWidgetId: Int): WidgetSortOrder { + val stored = prefs(context).getString(KEY_SORT_ORDER + appWidgetId, null) + ?: return WidgetSortOrder.NEWEST_FIRST + return runCatching { WidgetSortOrder.valueOf(stored) }.getOrDefault(WidgetSortOrder.NEWEST_FIRST) + } + + fun remove(context: Context, appWidgetId: Int) { + prefs(context).edit { + remove(KEY_FAVORITES_FIRST + appWidgetId) + remove(KEY_SORT_ORDER + appWidgetId) + } + } + + private fun prefs(context: Context) = + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/widget/interactivelist/InteractiveWidgetSelection.kt b/app/src/main/java/it/niedermann/owncloud/notes/widget/interactivelist/InteractiveWidgetSelection.kt new file mode 100644 index 000000000..88699aa0c --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/widget/interactivelist/InteractiveWidgetSelection.kt @@ -0,0 +1,35 @@ +/* + * Nextcloud Notes - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package it.niedermann.owncloud.notes.widget.interactivelist + +import it.niedermann.owncloud.notes.main.MainActivity +import it.niedermann.owncloud.notes.persistence.entity.NotesListWidgetData + +object InteractiveWidgetSelection { + private const val CATEGORY_ID_PREFIX = "category:" + private const val CATEGORY_SEPARATOR = '/' + + fun navigationItemId(data: NotesListWidgetData?): String { + if (data == null) { + return MainActivity.ADAPTER_KEY_RECENT + } + + return when (data.mode) { + NotesListWidgetData.MODE_DISPLAY_STARRED -> MainActivity.ADAPTER_KEY_STARRED + NotesListWidgetData.MODE_DISPLAY_CATEGORY -> categoryId(data.category) + else -> MainActivity.ADAPTER_KEY_RECENT + } + } + + private fun categoryId(category: String?): String { + if (category.isNullOrEmpty()) { + return MainActivity.ADAPTER_KEY_UNCATEGORIZED + } + + return CATEGORY_ID_PREFIX + category.substringBefore(CATEGORY_SEPARATOR) + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/widget/interactivelist/WidgetSortOrder.kt b/app/src/main/java/it/niedermann/owncloud/notes/widget/interactivelist/WidgetSortOrder.kt new file mode 100644 index 000000000..645b449e7 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/widget/interactivelist/WidgetSortOrder.kt @@ -0,0 +1,12 @@ +/* + * Nextcloud Notes - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package it.niedermann.owncloud.notes.widget.interactivelist + +enum class WidgetSortOrder { + NEWEST_FIRST, + OLDEST_FIRST +} diff --git a/app/src/main/res/drawable/interactive_note_list_widget_preview.webp b/app/src/main/res/drawable/interactive_note_list_widget_preview.webp new file mode 100644 index 000000000..367115b15 Binary files /dev/null and b/app/src/main/res/drawable/interactive_note_list_widget_preview.webp differ diff --git a/app/src/main/res/drawable/widget_card_background.xml b/app/src/main/res/drawable/widget_card_background.xml new file mode 100644 index 000000000..44654b997 --- /dev/null +++ b/app/src/main/res/drawable/widget_card_background.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/app/src/main/res/drawable/widget_fab_background.xml b/app/src/main/res/drawable/widget_fab_background.xml new file mode 100644 index 000000000..321ed54d6 --- /dev/null +++ b/app/src/main/res/drawable/widget_fab_background.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/widget_interactive_background.xml b/app/src/main/res/drawable/widget_interactive_background.xml new file mode 100644 index 000000000..aa3c3d2d4 --- /dev/null +++ b/app/src/main/res/drawable/widget_interactive_background.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/app/src/main/res/layout/activity_interactive_widget_configuration.xml b/app/src/main/res/layout/activity_interactive_widget_configuration.xml new file mode 100644 index 000000000..fed71a812 --- /dev/null +++ b/app/src/main/res/layout/activity_interactive_widget_configuration.xml @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/widget_interactive_entry.xml b/app/src/main/res/layout/widget_interactive_entry.xml new file mode 100644 index 000000000..3a5cb7875 --- /dev/null +++ b/app/src/main/res/layout/widget_interactive_entry.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/widget_interactive_note_list.xml b/app/src/main/res/layout/widget_interactive_note_list.xml new file mode 100644 index 000000000..c72bfdb58 --- /dev/null +++ b/app/src/main/res/layout/widget_interactive_note_list.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/widget_interactive_preview.xml b/app/src/main/res/layout/widget_interactive_preview.xml new file mode 100644 index 000000000..afd8ed8d6 --- /dev/null +++ b/app/src/main/res/layout/widget_interactive_preview.xml @@ -0,0 +1,128 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml index 0857691c8..e681da78d 100644 --- a/app/src/main/res/values-night/colors.xml +++ b/app/src/main/res/values-night/colors.xml @@ -35,6 +35,7 @@ #dd000000 #d8d8d8 + #2b2b2b #222222 #ffffff diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index f3a39be8a..a72fd5899 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -47,6 +47,7 @@ #ddffffff #222222 + #ffffff #ededed #666666 diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index b42e9c41b..cf8f26444 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -76,6 +76,10 @@ 4dp 26dp 20dp + 48dp + @dimen/spacer_2x + 11dp + 88dp 48dp diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 80d4284fc..509154536 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -245,6 +245,16 @@ Note not found Please login to Notes before using this widget Star icon is used to denote an item as a favorite + Interactive notes + Create note + Favorites + Favorites on top + Sort order + Newest first + Oldest first + Notes to show + Add widget + Edit widget Select note diff --git a/app/src/main/res/xml/interactive_note_list_widget_provider_info.xml b/app/src/main/res/xml/interactive_note_list_widget_provider_info.xml new file mode 100644 index 000000000..cd3f58f23 --- /dev/null +++ b/app/src/main/res/xml/interactive_note_list_widget_provider_info.xml @@ -0,0 +1,20 @@ + + + diff --git a/app/src/test/java/it/niedermann/owncloud/notes/persistence/WidgetNotesListDaoTest.kt b/app/src/test/java/it/niedermann/owncloud/notes/persistence/WidgetNotesListDaoTest.kt new file mode 100644 index 000000000..7e3ff6806 --- /dev/null +++ b/app/src/test/java/it/niedermann/owncloud/notes/persistence/WidgetNotesListDaoTest.kt @@ -0,0 +1,93 @@ +/* + * Nextcloud Notes - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package it.niedermann.owncloud.notes.persistence + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import it.niedermann.owncloud.notes.persistence.entity.Account +import it.niedermann.owncloud.notes.persistence.entity.NotesListWidgetData +import it.niedermann.owncloud.notes.shared.model.Capabilities +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [36]) +class WidgetNotesListDaoTest { + + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + private lateinit var db: NotesDatabase + private var accountId: Long = 0 + + @Before + fun setupDB() { + db = Room + .inMemoryDatabaseBuilder(ApplicationProvider.getApplicationContext(), NotesDatabase::class.java) + .allowMainThreadQueries() + .build() + + val account = Account(ACCOUNT_URL, ACCOUNT_USERNAME, ACCOUNT_NAME, null, Capabilities()) + accountId = db.accountDao.insert(account) + } + + @After + fun closeDb() { + db.close() + } + + @Test + fun insertThenReadWidgetData() { + db.widgetNotesListDao.createOrUpdateNoteListWidgetData( + widgetData(NotesListWidgetData.MODE_DISPLAY_ALL) + ) + + val loaded = db.widgetNotesListDao.getNoteListWidgetData(WIDGET_ID) + assertNotNull(loaded) + assertEquals(NotesListWidgetData.MODE_DISPLAY_ALL, loaded.mode) + } + + /** + * Reconfiguring an interactive widget saves again for the same appWidgetId (primary key). + * The DAO must upsert; a plain insert would abort on the primary-key conflict. + */ + @Test + fun reconfigureUpdatesExistingWidgetData() { + db.widgetNotesListDao.createOrUpdateNoteListWidgetData( + widgetData(NotesListWidgetData.MODE_DISPLAY_ALL) + ) + db.widgetNotesListDao.createOrUpdateNoteListWidgetData( + widgetData(NotesListWidgetData.MODE_DISPLAY_STARRED) + ) + + val loaded = db.widgetNotesListDao.getNoteListWidgetData(WIDGET_ID) + assertNotNull(loaded) + assertEquals(NotesListWidgetData.MODE_DISPLAY_STARRED, loaded.mode) + } + + private fun widgetData(mode: Int) = NotesListWidgetData().apply { + id = WIDGET_ID + accountId = this@WidgetNotesListDaoTest.accountId + this.mode = mode + category = null + } + + companion object { + private const val WIDGET_ID = 1 + private const val ACCOUNT_URL = "https://example.com" + private const val ACCOUNT_USERNAME = "user" + private const val ACCOUNT_NAME = "user@example.com" + } +}