Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
89383b3
chore: scaffold lists/messages/documents feature modules
Adron Jul 18, 2026
afc9799
feat: integrate lists, messages & documents feature modules
Adron Jul 18, 2026
d101d28
Merge branch 'feature/core-domains' into dev
Adron Jul 18, 2026
da65dcd
chore: scaffold :feature:profile module (Phase 2 base)
Adron Jul 18, 2026
c985443
feat: documents folder browser redesign + lists/messages deferred fea…
Adron Jul 18, 2026
e3e7490
feat: order home tabs Messages, Lists, Documents (match web app)
Adron Jul 18, 2026
354cd12
feat(lists): wire a Connections entry point on the Lists screen
Adron Jul 18, 2026
4b51649
chore: scaffold notifications/organizations/integrations feature modules
Adron Jul 18, 2026
da1e1c5
feat: following + notifications + organizations + integrations (Phase…
Adron Jul 18, 2026
7068e6f
fix(profile): don't nest verticalScroll in the Account hub
Adron Jul 18, 2026
c6d644e
feat(notifications): add Notification Preferences settings screen (Mi…
Adron Jul 31, 2026
cdd216e
feat(profile): add Account & Security surface (Milestone K)
Adron Jul 31, 2026
ff6111a
feat(dm): add Direct Messages module (Milestone A)
Adron Jul 31, 2026
09f2ada
feat(profile): browse others' public content (Milestone L)
Adron Jul 31, 2026
e1685c3
feat(lists,documents): sharing & share links (Milestone F)
Adron Jul 31, 2026
0c9da8e
feat(auth): complete account lifecycle (Milestone B)
Adron Jul 31, 2026
ea0db68
feat(lists): completeness polish (Milestone M, lists slice)
Adron Jul 31, 2026
9249601
feat(app): wire 7 parity milestones into navigation; app assembles
Adron Jul 31, 2026
610147a
feat(documents): collaboration + delta sync (Milestone G) + wire mana…
Adron Jul 31, 2026
9d22622
feat(documents): completeness polish (Milestone M, documents slice)
Adron Jul 31, 2026
fc5c039
feat(billing): subscription upsell + Stripe checkout/portal (Mileston…
Adron Aug 1, 2026
e1b8e70
feat(integrations): GitHub integration (Milestone N)
Adron Aug 1, 2026
55dd6ee
feat(profile): user moderation - block/mute/report (Milestone D, prof…
Adron Aug 1, 2026
8f7864e
feat(app): wire D moderation, J billing, N GitHub into nav + deep-links
Adron Aug 1, 2026
7ecf50b
chore: remove billing from Android (handled on web) + label device se…
Adron Aug 1, 2026
b5a475d
feat(app): make folder-browser (lists) and templates (documents) reac…
Adron Aug 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,9 @@ google-services.json

# Android Profiling
*.hprof

# Agent git worktrees (temporary, isolated builds)
.claude/worktrees/

# Stale IDE/compiler bin outputs
**/bin/
9 changes: 9 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,20 @@ dependencies {

// Features
implementation(project(":feature:auth"))
implementation(project(":feature:lists"))
implementation(project(":feature:messages"))
implementation(project(":feature:directmessages"))
implementation(project(":feature:documents"))
implementation(project(":feature:profile"))
implementation(project(":feature:notifications"))
implementation(project(":feature:organizations"))
implementation(project(":feature:integrations"))

// Compose
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.material.icons.extended)
implementation(libs.androidx.compose.ui.tooling.preview)
debugImplementation(libs.androidx.compose.ui.tooling)
implementation(libs.androidx.activity.compose)
Expand Down
30 changes: 29 additions & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">

<uses-permission android:name="android.permission.INTERNET" />

Expand All @@ -20,6 +21,33 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>

<!--
App Links: verified https:// deep links routed to the in-app nav
destinations (shared list/document tokens, password reset, email
verification). autoVerify enables Android App Links verification.
-->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="interlinedlist.com" />
<data android:pathPrefix="/lists/shared/" />
<data android:pathPrefix="/documents/shared/" />
<data android:path="/reset-password" />
<data android:path="/verify-email" />
</intent-filter>

<!-- Custom-scheme equivalents for the same in-app deep-link routes. -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="interlinedlist" android:host="lists" />
<data android:scheme="interlinedlist" android:host="documents" />
<data android:scheme="interlinedlist" android:host="reset-password" />
<data android:scheme="interlinedlist" android:host="verify-email" />
</intent-filter>
</activity>
</application>
</manifest>

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import kotlinx.serialization.Serializable
data class SyncTokenRequest(
val email: String,
val password: String,
/** Human-readable device name shown in the user's Active Sessions list. */
val deviceLabel: String? = null,
)

/** Response from `POST /api/auth/sync-token`; `token` is the `il_tok_...` value. */
Expand Down
7 changes: 7 additions & 0 deletions feature/auth/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,19 @@ dependencies {
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
implementation(libs.androidx.hilt.navigation.compose)
// The module owns its unauthenticated nav sub-graph (Login/Register/Forgot/Reset/Verify).
implementation(libs.androidx.navigation.compose)

// Unit tests
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.turbine)
testImplementation(libs.truth)
// Repository tests exercise the module-local AuthApi over MockWebServer.
testImplementation(libs.retrofit.core)
testImplementation(libs.okhttp.mockwebserver)
testImplementation(libs.kotlinx.serialization.json)
testImplementation(libs.retrofit.kotlinx.serialization)

// Instrumented / UI tests
androidTestImplementation(libs.androidx.test.ext.junit)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package com.interlinedlist.android.feature.auth.ui

import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class ForgotPasswordScreenTest {

@get:Rule
val composeRule = createComposeRule()

/**
* Hosts the stateless [ForgotPasswordScreen]. On submit it flips a local
* `emailSent` flag, standing in for the ViewModel's success transition so
* the confirmation swap can be asserted without a repository.
*/
private fun setForgot() {
composeRule.setContent {
var state by mutableStateOf(ForgotPasswordUiState())
InterlinedListTheme {
ForgotPasswordScreen(
state = state,
onEmailChange = { state = state.copy(email = it, errorMessage = null) },
onSubmit = { state = state.copy(emailSent = true) },
onBackToLogin = {},
)
}
}
}

@Test
fun submit_disabledUntilEmailEntered() {
setForgot()

composeRule.onNodeWithTag(ForgotPasswordTestTags.SUBMIT).assertIsNotEnabled()
composeRule.onNodeWithTag(ForgotPasswordTestTags.EMAIL).performTextInput("me@example.com")
composeRule.onNodeWithTag(ForgotPasswordTestTags.SUBMIT).assertIsEnabled()
}

@Test
fun submit_showsCheckYourEmailConfirmation() {
setForgot()

composeRule.onNodeWithTag(ForgotPasswordTestTags.EMAIL).performTextInput("me@example.com")
composeRule.onNodeWithTag(ForgotPasswordTestTags.SUBMIT).performClick()

// The form is replaced by the confirmation; the email field goes away.
composeRule.onNodeWithTag(ForgotPasswordTestTags.CONFIRMATION).assertExists()
composeRule.onNodeWithTag(ForgotPasswordTestTags.EMAIL).assertDoesNotExist()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package com.interlinedlist.android.feature.auth.ui

import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performTextInput
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class RegisterScreenTest {

@get:Rule
val composeRule = createComposeRule()

/** Hosts the stateless [RegisterScreen] with an in-memory state holder. */
private fun setRegister() {
composeRule.setContent {
var state by mutableStateOf(RegisterUiState())
InterlinedListTheme {
RegisterScreen(
state = state,
onDisplayNameChange = { state = state.copy(displayName = it, errorMessage = null) },
onUsernameChange = { state = state.copy(username = it, errorMessage = null) },
onEmailChange = { state = state.copy(email = it, errorMessage = null) },
onPasswordChange = { state = state.copy(password = it, errorMessage = null) },
onConfirmPasswordChange = { state = state.copy(confirmPassword = it, errorMessage = null) },
onSubmit = {},
onBackToLogin = {},
)
}
}
}

@Test
fun submit_disabledUntilAllRequiredFieldsFilledAndPasswordsMatch() {
setRegister()

composeRule.onNodeWithTag(RegisterTestTags.SUBMIT).assertIsNotEnabled()

composeRule.onNodeWithTag(RegisterTestTags.USERNAME).performTextInput("newbie")
composeRule.onNodeWithTag(RegisterTestTags.EMAIL).performTextInput("new@example.com")
composeRule.onNodeWithTag(RegisterTestTags.PASSWORD).performTextInput("s3cret!!")

// Mismatched confirmation keeps submit disabled and shows the hint.
composeRule.onNodeWithTag(RegisterTestTags.CONFIRM).performTextInput("different")
composeRule.onNodeWithTag(RegisterTestTags.SUBMIT).assertIsNotEnabled()
composeRule.onNodeWithTag(RegisterTestTags.MISMATCH).assertExists()
}

@Test
fun submit_enabledWhenPasswordsMatch() {
setRegister()

composeRule.onNodeWithTag(RegisterTestTags.USERNAME).performTextInput("newbie")
composeRule.onNodeWithTag(RegisterTestTags.EMAIL).performTextInput("new@example.com")
composeRule.onNodeWithTag(RegisterTestTags.PASSWORD).performTextInput("s3cret!!")
composeRule.onNodeWithTag(RegisterTestTags.CONFIRM).performTextInput("s3cret!!")

composeRule.onNodeWithTag(RegisterTestTags.SUBMIT).assertIsEnabled()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package com.interlinedlist.android.feature.auth.data
import com.interlinedlist.android.core.common.result.ApiResult
import com.interlinedlist.android.core.model.User

/** Authentication and session operations for the app. */
/** Authentication, account-lifecycle, and session operations for the app. */
interface AuthRepository {

/** Whether a bearer token is already persisted. */
Expand All @@ -15,6 +15,31 @@ interface AuthRepository {
*/
suspend fun login(email: String, password: String): ApiResult<User>

/**
* Creates an account, then signs in exactly the way [login] does (mint a
* sync-token + fetch the user), so the caller lands in the authed state with
* no session left behind on failure. The returned [User] carries
* `emailVerified` so the UI can surface an "unverified email" hint.
*/
suspend fun register(
email: String,
username: String,
password: String,
displayName: String?,
): ApiResult<User>

/** Starts a password reset; the server emails a tokenised reset link. */
suspend fun forgotPassword(email: String): ApiResult<Unit>

/** Completes a password reset with the emailed token and a new password. */
suspend fun resetPassword(token: String, newPassword: String): ApiResult<Unit>

/** Confirms an email address with the token from the verification link. */
suspend fun verifyEmail(token: String): ApiResult<Unit>

/** Resends the verification email to the signed-in (unverified) user. */
suspend fun resendVerificationEmail(): ApiResult<Unit>

/** Clears the persisted session and cached user. */
suspend fun logout()
}
Loading
Loading