From ca02084c1a29592e8a76bf926a834693c3985075 Mon Sep 17 00:00:00 2001 From: loloakira <6253351+loloakira@users.noreply.github.com> Date: Sat, 19 Sep 2026 00:26:12 +0200 Subject: [PATCH 1/2] fix(notes): strip the note title from the excerpt only when the first line matches generateNoteExcerpt removed the title from the content by string prefix, so a note titled "Asdasd" whose first line is "Asdasd1" lost the first six characters and showed "1". The title is now only dropped when the whole first line equals it, which is the case the excerpt is meant to avoid duplicating. Excerpts are stored in the database, so notes written before this change keep their truncated excerpt until they are saved or synchronised again. AI-assistant: Claude Code 2.1.226 (Claude Opus 5) Assisted-by: ClaudeCode:claude-opus-5 Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: loloakira <6253351+loloakira@users.noreply.github.com> --- .../it/niedermann/owncloud/notes/shared/util/NoteUtil.java | 6 ++++-- .../niedermann/owncloud/notes/shared/util/NoteUtilTest.kt | 7 +++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/it/niedermann/owncloud/notes/shared/util/NoteUtil.java b/app/src/main/java/it/niedermann/owncloud/notes/shared/util/NoteUtil.java index e6d8110e6..a93b99c4f 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/shared/util/NoteUtil.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/shared/util/NoteUtil.java @@ -85,8 +85,10 @@ public static String generateNoteExcerpt(@NonNull String content, @Nullable Stri if (!TextUtils.isEmpty(title)) { final String trimmedTitle = removeMarkdown(replaceCheckboxesWithEmojis(title.trim())); - if (result.startsWith(trimmedTitle)) { - result = result.substring(trimmedTitle.length()); + final int firstLineEnd = result.indexOf('\n'); + final String firstLine = firstLineEnd < 0 ? result : result.substring(0, firstLineEnd); + if (firstLine.equals(trimmedTitle)) { + result = firstLineEnd < 0 ? "" : result.substring(firstLineEnd + 1); } } diff --git a/app/src/test/java/it/niedermann/owncloud/notes/shared/util/NoteUtilTest.kt b/app/src/test/java/it/niedermann/owncloud/notes/shared/util/NoteUtilTest.kt index ed2495f92..c99482154 100644 --- a/app/src/test/java/it/niedermann/owncloud/notes/shared/util/NoteUtilTest.kt +++ b/app/src/test/java/it/niedermann/owncloud/notes/shared/util/NoteUtilTest.kt @@ -64,6 +64,7 @@ class NoteUtilTest : TestCase() { fun testGenerateNoteExcerpt() { testBasicExcerpts() testTitleMatchingExcerpts() + testTitleAsPrefixOfFirstLine() testEmptyLineHandling() testMarkdownInContent() testMarkdownInTitle() @@ -89,6 +90,12 @@ class NoteUtilTest : TestCase() { assertEquals("", NoteUtil.generateNoteExcerpt("", "Title")) } + private fun testTitleAsPrefixOfFirstLine() { + assertEquals("Asdasd1 Asda22", NoteUtil.generateNoteExcerpt("Asdasd1\nAsda22", "Asdasd")) + assertEquals("Titles Foo", NoteUtil.generateNoteExcerpt("Titles\nFoo", "Title")) + assertEquals("Title suffix", NoteUtil.generateNoteExcerpt("Title suffix", "Title")) + } + private fun testEmptyLineHandling() { // some empty lines between the actual contents → Should be ignored assertEquals("", NoteUtil.generateNoteExcerpt("\nTitle", "Title")) From 335890d3cd00683f02c80e4d11f8276ecf0041c3 Mon Sep 17 00:00:00 2001 From: loloakira <6253351+loloakira@users.noreply.github.com> Date: Thu, 4 Jun 2026 23:30:20 +0200 Subject: [PATCH 2/2] feat(widget): add interactive notes widget Add a third home-screen widget, "Interactive notes", that shows notes as a scrollable single column of rounded cards. Each card shows the note title and a multi-line content excerpt (the whole note when it is short). Cards scroll up to the top and bottom edges of the widget, and on Android 12+ the widget uses the system corner radius so the launcher clips them to its shape. Tapping a card opens that specific note; a floating button in the bottom-right creates a new note in the widget's configured category/account. The favorite star is a non-clickable indicator. The configuration screen groups its controls under headings and separates selecting from saving: tapping an entry only selects it, and an "Add widget" button in the bottom-right commits the configuration. "All notes" is selected by default. When an existing widget is reconfigured the button reads "Edit widget" and the stored selection is restored. Sort options are a "favorites on top" toggle and a newest-first / oldest-first order. The widget is reconfigurable via long-press on Android 12+ and provides a live previewLayout for the widget picker. Reuses the existing NotesListWidgetData storage (no database migration) and the existing excerpt generation; sort options are kept in per-widget SharedPreferences. Updates are routed through EXTRA_APPWIDGET_IDS. The two existing widgets are left unchanged. So reconfiguration can persist updated settings, the shared createOrUpdateNoteListWidgetData DAO now uses an upsert (REPLACE) conflict strategy instead of a plain insert that aborted on the existing primary key; this is transparent to the non-reconfigurable widgets, which never hit the conflict. A DAO test covers the upsert. Assisted-by: ClaudeCode:claude-opus-5 Assisted-by: ClaudeCode:claude-opus-5-5 AI-assistant: Claude Code 2.1.226 (Claude Opus 5) AI-assistant: Claude Code 2.1.289 (Claude Opus 5.5) Co-Authored-By: Claude Opus 5 (1M context) Co-Authored-By: Claude Opus 5.5 Signed-off-by: loloakira <6253351+loloakira@users.noreply.github.com> --- app/src/main/AndroidManifest.xml | 27 +++ .../notes/persistence/NotesRepository.java | 2 + .../persistence/dao/WidgetNotesListDao.java | 3 +- .../InteractiveNoteListWidget.kt | 149 ++++++++++++ ...tiveNoteListWidgetConfigurationActivity.kt | 229 ++++++++++++++++++ .../InteractiveNoteListWidgetFactory.kt | 182 ++++++++++++++ .../InteractiveNoteListWidgetService.kt | 15 ++ .../InteractiveWidgetPreferences.kt | 44 ++++ .../InteractiveWidgetSelection.kt | 35 +++ .../widget/interactivelist/WidgetSortOrder.kt | 12 + .../res/drawable/widget_card_background.xml | 12 + .../res/drawable/widget_fab_background.xml | 11 + .../widget_interactive_background.xml | 12 + ...ivity_interactive_widget_configuration.xml | 95 ++++++++ .../res/layout/widget_interactive_entry.xml | 73 ++++++ .../layout/widget_interactive_note_list.xml | 51 ++++ .../res/layout/widget_interactive_preview.xml | 131 ++++++++++ app/src/main/res/values-night/colors.xml | 1 + app/src/main/res/values-v31/dimens.xml | 2 + app/src/main/res/values/colors.xml | 1 + app/src/main/res/values/dimens.xml | 5 + app/src/main/res/values/strings.xml | 10 + ...ractive_note_list_widget_provider_info.xml | 21 ++ .../persistence/WidgetNotesListDaoTest.kt | 93 +++++++ 24 files changed, 1215 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/it/niedermann/owncloud/notes/widget/interactivelist/InteractiveNoteListWidget.kt create mode 100644 app/src/main/java/it/niedermann/owncloud/notes/widget/interactivelist/InteractiveNoteListWidgetConfigurationActivity.kt create mode 100644 app/src/main/java/it/niedermann/owncloud/notes/widget/interactivelist/InteractiveNoteListWidgetFactory.kt create mode 100644 app/src/main/java/it/niedermann/owncloud/notes/widget/interactivelist/InteractiveNoteListWidgetService.kt create mode 100644 app/src/main/java/it/niedermann/owncloud/notes/widget/interactivelist/InteractiveWidgetPreferences.kt create mode 100644 app/src/main/java/it/niedermann/owncloud/notes/widget/interactivelist/InteractiveWidgetSelection.kt create mode 100644 app/src/main/java/it/niedermann/owncloud/notes/widget/interactivelist/WidgetSortOrder.kt create mode 100644 app/src/main/res/drawable/widget_card_background.xml create mode 100644 app/src/main/res/drawable/widget_fab_background.xml create mode 100644 app/src/main/res/drawable/widget_interactive_background.xml create mode 100644 app/src/main/res/layout/activity_interactive_widget_configuration.xml create mode 100644 app/src/main/res/layout/widget_interactive_entry.xml create mode 100644 app/src/main/res/layout/widget_interactive_note_list.xml create mode 100644 app/src/main/res/layout/widget_interactive_preview.xml create mode 100644 app/src/main/res/xml/interactive_note_list_widget_provider_info.xml create mode 100644 app/src/test/java/it/niedermann/owncloud/notes/persistence/WidgetNotesListDaoTest.kt 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..332f823c4 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/widget/interactivelist/InteractiveNoteListWidget.kt @@ -0,0 +1,149 @@ +/* + * 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.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 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 { + 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 appWidgetIds = AppWidgetManager.getInstance(context) + .getAppWidgetIds(ComponentName(context, InteractiveNoteListWidget::class.java)) + + val intent = Intent(context, InteractiveNoteListWidget::class.java).apply { + action = AppWidgetManager.ACTION_APPWIDGET_UPDATE + putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, appWidgetIds) + } + 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..ef30cb03b --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/widget/interactivelist/InteractiveNoteListWidgetConfigurationActivity.kt @@ -0,0 +1,229 @@ +/* + * 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 + ).apply { + putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) + putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, intArrayOf(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/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..238fbddd4 --- /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..a605c6d31 --- /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..1343a104c --- /dev/null +++ b/app/src/main/res/layout/widget_interactive_note_list.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + 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..aa68e629c --- /dev/null +++ b/app/src/main/res/layout/widget_interactive_preview.xml @@ -0,0 +1,131 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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-v31/dimens.xml b/app/src/main/res/values-v31/dimens.xml index ee5173986..03edb85ee 100644 --- a/app/src/main/res/values-v31/dimens.xml +++ b/app/src/main/res/values-v31/dimens.xml @@ -11,4 +11,6 @@ @android:dimen/system_app_widget_background_radius @android:dimen/system_app_widget_background_radius @android:dimen/system_app_widget_inner_radius + @dimen/spacer_1hx + @android:dimen/system_app_widget_background_radius 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..4c41350ca 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -76,6 +76,11 @@ 4dp 26dp 20dp + 48dp + @dimen/spacer_2x + @dimen/spacer_1x + @dimen/card_radius + 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..fa5d1decb --- /dev/null +++ b/app/src/main/res/xml/interactive_note_list_widget_provider_info.xml @@ -0,0 +1,21 @@ + + + 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" + } +}