Skip to content

Commit db9f75d

Browse files
committed
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). 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 favourite star is a non-clickable indicator. The per-widget configuration adds sort options: a "favourites 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. 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-4-8 Signed-off-by: loloakira <6253351+loloakira@users.noreply.github.com>
1 parent 7322faf commit db9f75d

24 files changed

Lines changed: 1133 additions & 2 deletions

REUSE.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ SPDX-PackageSupplier = "Nextcloud Android team <android@nextcloud.com>"
66
SPDX-PackageDownloadLocation = "https://github.com/nextcloud/notes-android"
77

88
[[annotations]]
9-
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"]
9+
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"]
1010
precedence = "aggregate"
1111
SPDX-FileCopyrightText = "2016-2025 Nextcloud GmbH and Nextcloud contributors"
1212
SPDX-License-Identifier = "GPL-3.0-or-later"

app/src/main/AndroidManifest.xml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,14 @@
159159
</intent-filter>
160160
</activity>
161161

162+
<activity android:name=".widget.interactivelist.InteractiveNoteListWidgetConfigurationActivity"
163+
android:exported="false">
164+
165+
<intent-filter>
166+
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
167+
</intent-filter>
168+
</activity>
169+
162170
<receiver
163171
android:name=".widget.singlenote.SingleNoteWidget"
164172
android:label="@string/widget_single_note_title"
@@ -187,6 +195,20 @@
187195
android:resource="@xml/note_list_widget_provider_info" />
188196
</receiver>
189197

198+
<receiver
199+
android:name=".widget.interactivelist.InteractiveNoteListWidget"
200+
android:label="@string/widget_interactive_note_list_title"
201+
android:exported="false">
202+
203+
<intent-filter>
204+
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
205+
</intent-filter>
206+
207+
<meta-data
208+
android:name="android.appwidget.provider"
209+
android:resource="@xml/interactive_note_list_widget_provider_info" />
210+
</receiver>
211+
190212
<service
191213
android:name=".widget.singlenote.SingleNoteWidgetService"
192214
android:permission="android.permission.BIND_REMOTEVIEWS"
@@ -197,6 +219,11 @@
197219
android:permission="android.permission.BIND_REMOTEVIEWS"
198220
android:exported="false"/>
199221

222+
<service
223+
android:name=".widget.interactivelist.InteractiveNoteListWidgetService"
224+
android:permission="android.permission.BIND_REMOTEVIEWS"
225+
android:exported="false"/>
226+
200227
<service
201228
android:name=".quicksettings.NewNoteTileService"
202229
android:description="@string/action_create"

app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesRepository.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import static it.niedermann.owncloud.notes.edit.EditNoteActivity.ACTION_SHORTCUT;
1515
import static it.niedermann.owncloud.notes.shared.util.ApiVersionUtil.getPreferredApiVersion;
1616
import static it.niedermann.owncloud.notes.shared.util.NoteUtil.generateNoteExcerpt;
17+
import static it.niedermann.owncloud.notes.widget.interactivelist.InteractiveNoteListWidget.updateInteractiveNoteListWidgets;
1718
import static it.niedermann.owncloud.notes.widget.notelist.NoteListWidget.updateNoteListWidgets;
1819
import static it.niedermann.owncloud.notes.widget.singlenote.SingleNoteWidget.updateSingleNoteWidgets;
1920

@@ -677,6 +678,7 @@ private void notifyWidgets() {
677678
executor.submit(() -> {
678679
updateSingleNoteWidgets(context);
679680
updateNoteListWidgets(context);
681+
updateInteractiveNoteListWidgets(context);
680682
});
681683
}
682684

app/src/main/java/it/niedermann/owncloud/notes/persistence/dao/WidgetNotesListDao.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,15 @@
88

99
import androidx.room.Dao;
1010
import androidx.room.Insert;
11+
import androidx.room.OnConflictStrategy;
1112
import androidx.room.Query;
1213

1314
import it.niedermann.owncloud.notes.persistence.entity.NotesListWidgetData;
1415

1516
@Dao
1617
public interface WidgetNotesListDao {
1718

18-
@Insert
19+
@Insert(onConflict = OnConflictStrategy.REPLACE)
1920
void createOrUpdateNoteListWidgetData(NotesListWidgetData data);
2021

2122
@Query("DELETE FROM NOTESLISTWIDGETDATA WHERE id = :appWidgetId")
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
/*
2+
* Nextcloud Notes - Android Client
3+
*
4+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
5+
* SPDX-License-Identifier: GPL-3.0-or-later
6+
*/
7+
package it.niedermann.owncloud.notes.widget.interactivelist
8+
9+
import android.app.PendingIntent
10+
import android.appwidget.AppWidgetManager
11+
import android.appwidget.AppWidgetProvider
12+
import android.content.ComponentName
13+
import android.content.Context
14+
import android.content.Intent
15+
import android.content.res.ColorStateList
16+
import android.os.Build
17+
import android.os.Bundle
18+
import android.util.Log
19+
import android.widget.RemoteViews
20+
import androidx.core.net.toUri
21+
import it.niedermann.owncloud.notes.R
22+
import it.niedermann.owncloud.notes.branding.BrandingUtil
23+
import it.niedermann.owncloud.notes.edit.EditNoteActivity
24+
import it.niedermann.owncloud.notes.persistence.NotesRepository
25+
import it.niedermann.owncloud.notes.persistence.entity.NotesListWidgetData
26+
import it.niedermann.owncloud.notes.shared.model.ENavigationCategoryType
27+
import it.niedermann.owncloud.notes.shared.model.NavigationCategory
28+
import it.niedermann.owncloud.notes.shared.util.WidgetUtil
29+
import java.util.concurrent.ExecutorService
30+
import java.util.concurrent.Executors
31+
32+
class InteractiveNoteListWidget : AppWidgetProvider() {
33+
private val executor: ExecutorService = Executors.newCachedThreadPool()
34+
35+
override fun onUpdate(
36+
context: Context,
37+
appWidgetManager: AppWidgetManager,
38+
appWidgetIds: IntArray
39+
) {
40+
super.onUpdate(context, appWidgetManager, appWidgetIds)
41+
updateAppWidget(context, appWidgetManager, appWidgetIds)
42+
}
43+
44+
override fun onReceive(context: Context, intent: Intent) {
45+
super.onReceive(context, intent)
46+
val awm = AppWidgetManager.getInstance(context)
47+
48+
if (intent.action == null) {
49+
Log.w(TAG, "Intent action is null")
50+
return
51+
}
52+
53+
if (intent.action != AppWidgetManager.ACTION_APPWIDGET_UPDATE) {
54+
Log.w(TAG, "Intent action is not ACTION_APPWIDGET_UPDATE")
55+
return
56+
}
57+
58+
if (!intent.hasExtra(AppWidgetManager.EXTRA_APPWIDGET_ID)) {
59+
updateAppWidget(
60+
context,
61+
awm,
62+
awm.getAppWidgetIds(ComponentName(context, InteractiveNoteListWidget::class.java))
63+
)
64+
}
65+
66+
val appWidgetIds = intArrayOf(intent.extras?.getInt(AppWidgetManager.EXTRA_APPWIDGET_ID, -1) ?: -1)
67+
68+
updateAppWidget(
69+
context,
70+
awm,
71+
appWidgetIds
72+
)
73+
}
74+
75+
override fun onDeleted(context: Context, appWidgetIds: IntArray) {
76+
super.onDeleted(context, appWidgetIds)
77+
val repo = NotesRepository.getInstance(context)
78+
79+
for (appWidgetId in appWidgetIds) {
80+
executor.execute {
81+
repo.removeNoteListWidget(appWidgetId)
82+
InteractiveWidgetPreferences.remove(context, appWidgetId)
83+
}
84+
}
85+
}
86+
87+
companion object {
88+
private val TAG: String = InteractiveNoteListWidget::class.java.simpleName
89+
90+
fun updateAppWidget(context: Context, awm: AppWidgetManager, appWidgetIds: IntArray) {
91+
val repo = NotesRepository.getInstance(context)
92+
appWidgetIds.forEach { appWidgetId ->
93+
val data = repo.getNoteListWidgetData(appWidgetId) ?: return@forEach
94+
updateSingleWidget(context, awm, appWidgetId, data)
95+
}
96+
}
97+
98+
private fun updateSingleWidget(
99+
context: Context,
100+
awm: AppWidgetManager,
101+
appWidgetId: Int,
102+
data: NotesListWidgetData
103+
) {
104+
val serviceIntent = Intent(context, InteractiveNoteListWidgetService::class.java).apply {
105+
putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
106+
setData(toUri(Intent.URI_INTENT_SCHEME).toUri())
107+
}
108+
109+
val openTemplateIntent = Intent(context, EditNoteActivity::class.java).apply {
110+
setPackage(context.packageName)
111+
}
112+
val openTemplateFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
113+
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
114+
} else {
115+
PendingIntent.FLAG_UPDATE_CURRENT
116+
}
117+
val openTemplatePendingIntent =
118+
PendingIntent.getActivity(context, 0, openTemplateIntent, openTemplateFlags)
119+
120+
val createNotePendingIntent =
121+
PendingIntent.getActivity(
122+
context,
123+
appWidgetId,
124+
createNoteIntent(context, data),
125+
WidgetUtil.pendingIntentFlagCompat(PendingIntent.FLAG_UPDATE_CURRENT)
126+
)
127+
128+
val views = RemoteViews(context.packageName, R.layout.widget_interactive_note_list).apply {
129+
setRemoteAdapter(R.id.interactive_note_list_lv, serviceIntent)
130+
setPendingIntentTemplate(R.id.interactive_note_list_lv, openTemplatePendingIntent)
131+
setEmptyView(
132+
R.id.interactive_note_list_lv,
133+
R.id.interactive_note_list_placeholder_tv
134+
)
135+
setOnClickPendingIntent(R.id.interactive_create_note_button, createNotePendingIntent)
136+
137+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
138+
setColorStateList(
139+
R.id.interactive_create_note_button,
140+
"setBackgroundTintList",
141+
ColorStateList.valueOf(BrandingUtil.readBrandMainColor(context))
142+
)
143+
}
144+
}
145+
146+
awm.run {
147+
updateAppWidget(appWidgetId, views)
148+
notifyAppWidgetViewDataChanged(intArrayOf(appWidgetId), R.id.interactive_note_list_lv)
149+
}
150+
}
151+
152+
private fun createNoteIntent(context: Context, data: NotesListWidgetData): Intent {
153+
val navigationCategory = if (data.mode == NotesListWidgetData.MODE_DISPLAY_STARRED) {
154+
NavigationCategory(ENavigationCategoryType.FAVORITES)
155+
} else {
156+
NavigationCategory(data.accountId, data.category)
157+
}
158+
159+
val bundle = Bundle().apply {
160+
putSerializable(EditNoteActivity.PARAM_CATEGORY, navigationCategory)
161+
putLong(EditNoteActivity.PARAM_ACCOUNT_ID, data.accountId)
162+
}
163+
164+
return Intent(context, EditNoteActivity::class.java).apply {
165+
setPackage(context.packageName)
166+
putExtras(bundle)
167+
setData("interactive-create://${data.id}".toUri())
168+
}
169+
}
170+
171+
@JvmStatic
172+
fun updateInteractiveNoteListWidgets(context: Context) {
173+
val intent = Intent(context, InteractiveNoteListWidget::class.java).apply {
174+
action = AppWidgetManager.ACTION_APPWIDGET_UPDATE
175+
}
176+
context.sendBroadcast(intent)
177+
}
178+
}
179+
}

0 commit comments

Comments
 (0)