Skip to content

Commit da1e1c5

Browse files
Adronclaude
andcommitted
feat: following + notifications + organizations + integrations (Phases 5/7/8)
Integrates four modules and restructures the Account tab into a hub: - profile: Following — follow/unfollow, follower/following lists, follow requests; Account screen becomes a hub (Followers/Following/Requests/Notifications/ Organizations/Integrations/Edit/Search/Sign out) (77 tests) - notifications (new): polled notifications list, mark read / mark-all / dismiss (42 tests) - organizations (new, Phase 7): org list/create + detail + member management (37 tests) - integrations (new, Phase 8): CSV data exports via share sheet + read-only connected-accounts status; OAuth connect flows deferred (13 tests) Nav rewired: Account hub wires Notifications/Organizations/Integrations to the new modules; followers/following/user-profile drill-down; ProfileRoute's follower/following callbacks pass the current username. Feature UI uses MaterialTheme.colorScheme roles (no brand-color constants), so it is independent of the design-system palette. Built by four parallel worktree agents (resynced to origin/dev), then integrated. :app:assembleDebug + full testDebugUnitTest green (366 unit tests). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 4b51649 commit da1e1c5

113 files changed

Lines changed: 9522 additions & 114 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

app/build.gradle.kts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@ dependencies {
5353
implementation(project(":feature:messages"))
5454
implementation(project(":feature:documents"))
5555
implementation(project(":feature:profile"))
56+
implementation(project(":feature:notifications"))
57+
implementation(project(":feature:organizations"))
58+
implementation(project(":feature:integrations"))
5659

5760
// Compose
5861
implementation(platform(libs.androidx.compose.bom))

app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt

Lines changed: 119 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ import com.interlinedlist.android.feature.auth.ui.LoginRoute
2828
import com.interlinedlist.android.feature.documents.ui.browser.DocumentsFolderRoute
2929
import com.interlinedlist.android.feature.documents.ui.browser.DocumentsRoute
3030
import com.interlinedlist.android.feature.documents.ui.editor.DocumentEditorRoute
31+
import com.interlinedlist.android.feature.integrations.ui.accounts.ConnectedAccountsRoute
32+
import com.interlinedlist.android.feature.integrations.ui.export.ExportRoute
33+
import com.interlinedlist.android.feature.integrations.ui.hub.IntegrationsRoute
3134
import com.interlinedlist.android.feature.lists.ui.connections.ConnectionsRoute
3235
import com.interlinedlist.android.feature.lists.ui.detail.ListDetailRoute
3336
import com.interlinedlist.android.feature.lists.ui.list.ListsRoute
@@ -36,7 +39,13 @@ import com.interlinedlist.android.feature.lists.ui.watchers.WatchersRoute
3639
import com.interlinedlist.android.feature.messages.ui.detail.MessageDetailRoute
3740
import com.interlinedlist.android.feature.messages.ui.feed.MessagesRoute
3841
import com.interlinedlist.android.feature.messages.ui.scheduled.ScheduledMessagesRoute
42+
import com.interlinedlist.android.feature.notifications.ui.NotificationsRoute
43+
import com.interlinedlist.android.feature.organizations.ui.detail.OrganizationDetailRoute
44+
import com.interlinedlist.android.feature.organizations.ui.list.OrganizationsRoute
3945
import com.interlinedlist.android.feature.profile.ui.edit.EditProfileRoute
46+
import com.interlinedlist.android.feature.profile.ui.follow.FollowRequestsRoute
47+
import com.interlinedlist.android.feature.profile.ui.follow.FollowersRoute
48+
import com.interlinedlist.android.feature.profile.ui.follow.FollowingRoute
4049
import com.interlinedlist.android.feature.profile.ui.profile.ProfileRoute
4150
import com.interlinedlist.android.feature.profile.ui.profile.UserProfileRoute
4251
import com.interlinedlist.android.feature.profile.ui.search.UserSearchRoute
@@ -48,8 +57,8 @@ object Routes {
4857
const val MAIN = "main"
4958

5059
// Top-level tabs (bottom navigation).
51-
const val LISTS = "lists"
5260
const val MESSAGES = "messages"
61+
const val LISTS = "lists"
5362
const val DOCUMENTS = "documents"
5463
const val ACCOUNT = "account"
5564

@@ -67,11 +76,22 @@ object Routes {
6776
const val DOCUMENT_FOLDER = "documents/folder/{folderId}"
6877
const val DOCUMENT_EDITOR = "documents/editor/{documentId}"
6978

70-
// Profile destinations. Distinct prefixes so a username can never collide
71-
// with the edit/search routes.
79+
// Profile / following destinations. Distinct prefixes so a username can
80+
// never collide with the edit/search/list routes.
7281
const val PROFILE_EDIT = "editProfile"
7382
const val USER_SEARCH = "userSearch"
7483
const val USER_PROFILE = "user/{username}"
84+
const val FOLLOWERS = "followers/{username}"
85+
const val FOLLOWING = "following/{username}"
86+
const val FOLLOW_REQUESTS = "followRequests"
87+
88+
// Notifications / organizations / integrations (reached from the Account hub).
89+
const val NOTIFICATIONS = "notifications"
90+
const val ORGANIZATIONS = "organizations"
91+
const val ORGANIZATION_DETAIL = "organizations/{orgId}"
92+
const val INTEGRATIONS = "integrations"
93+
const val INTEGRATIONS_EXPORT = "integrations/export"
94+
const val INTEGRATIONS_ACCOUNTS = "integrations/accounts"
7595

7696
fun listDetail(id: String) = "lists/$id"
7797
fun listSchema(id: String) = "lists/$id/schema"
@@ -80,6 +100,9 @@ object Routes {
80100
fun documentFolder(id: String) = "documents/folder/$id"
81101
fun documentEditor(id: String) = "documents/editor/$id"
82102
fun userProfile(username: String) = "user/$username"
103+
fun followers(username: String) = "followers/$username"
104+
fun following(username: String) = "following/$username"
105+
fun organization(orgId: String) = "organizations/$orgId"
83106
}
84107

85108
/**
@@ -168,6 +191,26 @@ private fun MainShell(onLoggedOut: () -> Unit) {
168191
startDestination = Routes.MESSAGES,
169192
modifier = Modifier.padding(padding),
170193
) {
194+
// ---- Messages ----
195+
composable(Routes.MESSAGES) {
196+
MessagesRoute(
197+
onOpenMessage = { id -> tabNav.navigate(Routes.messageDetail(id)) },
198+
onOpenScheduled = { tabNav.navigate(Routes.MESSAGES_SCHEDULED) },
199+
)
200+
}
201+
composable(
202+
Routes.MESSAGE_DETAIL,
203+
arguments = listOf(navArgument("messageId") { type = NavType.StringType }),
204+
) {
205+
MessageDetailRoute(
206+
onBack = { tabNav.popBackStack() },
207+
onOpenMessage = { id -> tabNav.navigate(Routes.messageDetail(id)) },
208+
)
209+
}
210+
composable(Routes.MESSAGES_SCHEDULED) {
211+
ScheduledMessagesRoute(onBack = { tabNav.popBackStack() })
212+
}
213+
171214
// ---- Lists ----
172215
composable(Routes.LISTS) {
173216
ListsRoute(
@@ -206,26 +249,6 @@ private fun MainShell(onLoggedOut: () -> Unit) {
206249
ConnectionsRoute(onBack = { tabNav.popBackStack() })
207250
}
208251

209-
// ---- Messages ----
210-
composable(Routes.MESSAGES) {
211-
MessagesRoute(
212-
onOpenMessage = { id -> tabNav.navigate(Routes.messageDetail(id)) },
213-
onOpenScheduled = { tabNav.navigate(Routes.MESSAGES_SCHEDULED) },
214-
)
215-
}
216-
composable(
217-
Routes.MESSAGE_DETAIL,
218-
arguments = listOf(navArgument("messageId") { type = NavType.StringType }),
219-
) {
220-
MessageDetailRoute(
221-
onBack = { tabNav.popBackStack() },
222-
onOpenMessage = { id -> tabNav.navigate(Routes.messageDetail(id)) },
223-
)
224-
}
225-
composable(Routes.MESSAGES_SCHEDULED) {
226-
ScheduledMessagesRoute(onBack = { tabNav.popBackStack() })
227-
}
228-
229252
// ---- Documents ----
230253
composable(Routes.DOCUMENTS) {
231254
DocumentsRoute(
@@ -253,14 +276,20 @@ private fun MainShell(onLoggedOut: () -> Unit) {
253276
)
254277
}
255278

256-
// ---- Account / Profile ----
279+
// ---- Account / Profile hub ----
257280
composable(Routes.ACCOUNT) {
258281
// Sign-out reuses the existing auth-backed logout; the profile
259282
// module intentionally owns no session state.
260283
val logoutViewModel: HomeViewModel = hiltViewModel()
261284
ProfileRoute(
262285
onEditProfile = { tabNav.navigate(Routes.PROFILE_EDIT) },
263286
onSearchUsers = { tabNav.navigate(Routes.USER_SEARCH) },
287+
onOpenFollowers = { username -> tabNav.navigate(Routes.followers(username)) },
288+
onOpenFollowing = { username -> tabNav.navigate(Routes.following(username)) },
289+
onOpenRequests = { tabNav.navigate(Routes.FOLLOW_REQUESTS) },
290+
onOpenNotifications = { tabNav.navigate(Routes.NOTIFICATIONS) },
291+
onOpenOrganizations = { tabNav.navigate(Routes.ORGANIZATIONS) },
292+
onOpenIntegrations = { tabNav.navigate(Routes.INTEGRATIONS) },
264293
onSignOut = { logoutViewModel.logout(onLoggedOut) },
265294
)
266295
}
@@ -280,7 +309,72 @@ private fun MainShell(onLoggedOut: () -> Unit) {
280309
Routes.USER_PROFILE,
281310
arguments = listOf(navArgument("username") { type = NavType.StringType }),
282311
) {
283-
UserProfileRoute(onBack = { tabNav.popBackStack() })
312+
UserProfileRoute(
313+
onBack = { tabNav.popBackStack() },
314+
onOpenFollowers = { username -> tabNav.navigate(Routes.followers(username)) },
315+
onOpenFollowing = { username -> tabNav.navigate(Routes.following(username)) },
316+
)
317+
}
318+
composable(
319+
Routes.FOLLOWERS,
320+
arguments = listOf(navArgument("username") { type = NavType.StringType }),
321+
) {
322+
FollowersRoute(
323+
onOpenUser = { username -> tabNav.navigate(Routes.userProfile(username)) },
324+
onBack = { tabNav.popBackStack() },
325+
)
326+
}
327+
composable(
328+
Routes.FOLLOWING,
329+
arguments = listOf(navArgument("username") { type = NavType.StringType }),
330+
) {
331+
FollowingRoute(
332+
onOpenUser = { username -> tabNav.navigate(Routes.userProfile(username)) },
333+
onBack = { tabNav.popBackStack() },
334+
)
335+
}
336+
composable(Routes.FOLLOW_REQUESTS) {
337+
FollowRequestsRoute(
338+
onOpenUser = { username -> tabNav.navigate(Routes.userProfile(username)) },
339+
onBack = { tabNav.popBackStack() },
340+
)
341+
}
342+
343+
// ---- Notifications ----
344+
composable(Routes.NOTIFICATIONS) {
345+
NotificationsRoute(onBack = { tabNav.popBackStack() })
346+
}
347+
348+
// ---- Organizations ----
349+
composable(Routes.ORGANIZATIONS) {
350+
OrganizationsRoute(
351+
onOpenOrg = { id -> tabNav.navigate(Routes.organization(id)) },
352+
onBack = { tabNav.popBackStack() },
353+
)
354+
}
355+
composable(
356+
Routes.ORGANIZATION_DETAIL,
357+
arguments = listOf(navArgument("orgId") { type = NavType.StringType }),
358+
) {
359+
OrganizationDetailRoute(
360+
onBack = { tabNav.popBackStack() },
361+
onDeleted = { tabNav.popBackStack() },
362+
)
363+
}
364+
365+
// ---- Integrations & exports ----
366+
composable(Routes.INTEGRATIONS) {
367+
IntegrationsRoute(
368+
onBack = { tabNav.popBackStack() },
369+
onOpenExport = { tabNav.navigate(Routes.INTEGRATIONS_EXPORT) },
370+
onOpenConnectedAccounts = { tabNav.navigate(Routes.INTEGRATIONS_ACCOUNTS) },
371+
)
372+
}
373+
composable(Routes.INTEGRATIONS_EXPORT) {
374+
ExportRoute(onBack = { tabNav.popBackStack() })
375+
}
376+
composable(Routes.INTEGRATIONS_ACCOUNTS) {
377+
ConnectedAccountsRoute(onBack = { tabNav.popBackStack() })
284378
}
285379
}
286380
}

feature/integrations/build.gradle.kts

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -40,23 +40,19 @@ dependencies {
4040
debugImplementation(libs.androidx.compose.ui.tooling)
4141
implementation(libs.androidx.lifecycle.viewmodel.compose)
4242
implementation(libs.androidx.lifecycle.runtime.compose)
43-
// Photo Picker (rememberLauncherForActivityResult) for document image uploads.
4443
implementation(libs.androidx.activity.compose)
4544

46-
// This module owns its own Room cache (see DocumentsDatabase) — it must not
47-
// reuse the shared :core:database, so it pulls Room in directly.
48-
implementation(libs.room.runtime)
49-
implementation(libs.room.ktx)
50-
ksp(libs.room.compiler)
45+
// androidx.core.content.FileProvider hands a cached CSV to the Android share sheet.
46+
// This module has no Room cache: exports stream fresh CSV and status/limits are
47+
// lightweight live reads, so there is nothing worth persisting locally.
48+
implementation(libs.androidx.core.ktx)
5149

5250
implementation(libs.hilt.android)
5351
ksp(libs.hilt.compiler)
5452
implementation(libs.androidx.hilt.navigation.compose)
5553

56-
implementation(libs.coil.compose)
57-
5854
implementation(libs.retrofit.core)
59-
// okhttp is used directly for multipart image uploads (MultipartBody / RequestBody).
55+
// okhttp is used directly for the streaming CSV ResponseBody.
6056
implementation(libs.okhttp.core)
6157
implementation(libs.kotlinx.serialization.json)
6258

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package com.interlinedlist.android.feature.integrations.ui
2+
3+
import androidx.compose.ui.test.assertIsDisplayed
4+
import androidx.compose.ui.test.junit4.createComposeRule
5+
import androidx.compose.ui.test.onNodeWithTag
6+
import androidx.compose.ui.test.performClick
7+
import androidx.test.ext.junit.runners.AndroidJUnit4
8+
import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme
9+
import com.interlinedlist.android.feature.integrations.domain.ConnectedAccount
10+
import com.interlinedlist.android.feature.integrations.domain.ExportType
11+
import com.interlinedlist.android.feature.integrations.ui.accounts.ConnectedAccountsScreen
12+
import com.interlinedlist.android.feature.integrations.ui.accounts.ConnectedAccountsTestTags
13+
import com.interlinedlist.android.feature.integrations.ui.accounts.ConnectedAccountsUiState
14+
import com.interlinedlist.android.feature.integrations.ui.export.ExportScreen
15+
import com.interlinedlist.android.feature.integrations.ui.export.ExportTestTags
16+
import com.interlinedlist.android.feature.integrations.ui.export.ExportUiState
17+
import com.interlinedlist.android.feature.integrations.ui.hub.IntegrationsHubScreen
18+
import com.interlinedlist.android.feature.integrations.ui.hub.IntegrationsHubTestTags
19+
import com.interlinedlist.android.feature.integrations.ui.hub.IntegrationsHubUiState
20+
import org.junit.Rule
21+
import org.junit.Test
22+
import org.junit.runner.RunWith
23+
24+
@RunWith(AndroidJUnit4::class)
25+
class IntegrationsScreensTest {
26+
27+
@get:Rule
28+
val composeRule = createComposeRule()
29+
30+
@Test
31+
fun hub_entries_navigateToSubScreens() {
32+
var export = false
33+
var accounts = false
34+
composeRule.setContent {
35+
InterlinedListTheme {
36+
IntegrationsHubScreen(
37+
state = IntegrationsHubUiState(isLoadingLimits = false),
38+
onBack = {},
39+
onOpenExport = { export = true },
40+
onOpenConnectedAccounts = { accounts = true },
41+
)
42+
}
43+
}
44+
45+
composeRule.onNodeWithTag(IntegrationsHubTestTags.EXPORT).performClick()
46+
composeRule.onNodeWithTag(IntegrationsHubTestTags.ACCOUNTS).performClick()
47+
48+
assert(export)
49+
assert(accounts)
50+
}
51+
52+
@Test
53+
fun export_row_tap_triggersExport() {
54+
var requested: ExportType? = null
55+
composeRule.setContent {
56+
InterlinedListTheme {
57+
ExportScreen(
58+
state = ExportUiState(),
59+
onExport = { requested = it },
60+
onDismissError = {},
61+
onBack = {},
62+
)
63+
}
64+
}
65+
66+
composeRule.onNodeWithTag(ExportTestTags.button(ExportType.FOLLOWS)).performClick()
67+
68+
assert(requested == ExportType.FOLLOWS)
69+
}
70+
71+
@Test
72+
fun accounts_showsConnectedStatus() {
73+
composeRule.setContent {
74+
InterlinedListTheme {
75+
ConnectedAccountsScreen(
76+
state = ConnectedAccountsUiState(
77+
isLoading = false,
78+
accounts = listOf(
79+
ConnectedAccount(ConnectedAccount.Provider.GITHUB, isConnected = true, handle = "@adron"),
80+
ConnectedAccount(ConnectedAccount.Provider.BLUESKY, isConnected = false),
81+
),
82+
),
83+
onBack = {},
84+
)
85+
}
86+
}
87+
88+
composeRule.onNodeWithTag(ConnectedAccountsTestTags.row(ConnectedAccount.Provider.GITHUB))
89+
.assertIsDisplayed()
90+
composeRule.onNodeWithTag(ConnectedAccountsTestTags.status(ConnectedAccount.Provider.GITHUB))
91+
.assertIsDisplayed()
92+
composeRule.onNodeWithTag(ConnectedAccountsTestTags.status(ConnectedAccount.Provider.BLUESKY))
93+
.assertIsDisplayed()
94+
}
95+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
3+
4+
<!--
5+
Exposes cached CSV exports to the system share sheet. The authority is
6+
derived from the host app's applicationId so it stays unique per install;
7+
ExportSharing builds the same string at runtime.
8+
-->
9+
<application>
10+
<provider
11+
android:name="androidx.core.content.FileProvider"
12+
android:authorities="${applicationId}.integrations.fileprovider"
13+
android:exported="false"
14+
android:grantUriPermissions="true">
15+
<meta-data
16+
android:name="android.support.FILE_PROVIDER_PATHS"
17+
android:resource="@xml/file_paths" />
18+
</provider>
19+
</application>
20+
</manifest>

0 commit comments

Comments
 (0)