WIP: Add database/networking and login

This commit is contained in:
William Brawner 2021-05-15 09:34:13 -06:00
parent ec96e39580
commit 7cc1ae892f
30 changed files with 1738 additions and 21 deletions

View file

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>

View file

@ -2,6 +2,7 @@ plugins {
id("com.android.application")
id("kotlin-android")
id("kotlin-kapt")
id("dagger.hilt.android.plugin")
}
android {
@ -52,7 +53,7 @@ android {
}
composeOptions {
kotlinCompilerExtensionVersion = libs.versions.compose.get()
kotlinCompilerVersion = libs.versions.kotlin.get()
kotlinCompilerVersion = rootProject.extra["kotlinVersion"] as String
}
}
@ -62,11 +63,20 @@ dependencies {
implementation(libs.androidx.appcompat)
implementation(libs.material)
implementation(libs.bundles.compose)
implementation(libs.preference)
implementation(libs.lifecycle)
implementation(libs.timber)
implementation(libs.room.ktx)
kapt(libs.room.kapt)
implementation(libs.moshi.core)
implementation(libs.bundles.networking)
implementation(libs.bundles.moshi)
kapt(libs.moshi.kapt)
implementation(libs.hilt.android.core)
kapt(libs.hilt.android.kapt)
implementation(libs.hilt.work.core)
kapt(libs.hilt.work.kapt)
implementation(libs.work.core)
testImplementation(libs.work.test)
testImplementation(libs.junit)
testImplementation(libs.room.test)
androidTestImplementation(libs.test.ext)

View file

@ -0,0 +1,432 @@
{
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "a3ba65cc1674ea74c169d8f134ff3c42",
"entities": [
{
"tableName": "Feed",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `userId` INTEGER NOT NULL, `title` TEXT NOT NULL, `siteUrl` TEXT NOT NULL, `feedUrl` TEXT NOT NULL, `checkedAt` INTEGER NOT NULL, `etagHeader` TEXT NOT NULL, `lastModifiedHeader` TEXT NOT NULL, `parsingErrorMessage` TEXT NOT NULL, `parsingErrorCount` INTEGER NOT NULL, `scraperRules` TEXT NOT NULL, `rewriteRules` TEXT NOT NULL, `crawler` INTEGER NOT NULL, `blocklistRules` TEXT NOT NULL, `keeplistRules` TEXT NOT NULL, `userAgent` TEXT NOT NULL, `username` TEXT NOT NULL, `password` TEXT NOT NULL, `disabled` INTEGER NOT NULL, `ignoreHttpCache` INTEGER NOT NULL, `fetchViaProxy` INTEGER NOT NULL, `categoryId` INTEGER NOT NULL, `iconId` INTEGER, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "siteUrl",
"columnName": "siteUrl",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "feedUrl",
"columnName": "feedUrl",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "checkedAt",
"columnName": "checkedAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "etagHeader",
"columnName": "etagHeader",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastModifiedHeader",
"columnName": "lastModifiedHeader",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "parsingErrorMessage",
"columnName": "parsingErrorMessage",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "parsingErrorCount",
"columnName": "parsingErrorCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "scraperRules",
"columnName": "scraperRules",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "rewriteRules",
"columnName": "rewriteRules",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "crawler",
"columnName": "crawler",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "blocklistRules",
"columnName": "blocklistRules",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "keeplistRules",
"columnName": "keeplistRules",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "userAgent",
"columnName": "userAgent",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "username",
"columnName": "username",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "password",
"columnName": "password",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "disabled",
"columnName": "disabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "ignoreHttpCache",
"columnName": "ignoreHttpCache",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "fetchViaProxy",
"columnName": "fetchViaProxy",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "categoryId",
"columnName": "categoryId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "iconId",
"columnName": "iconId",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "Entry",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `userId` INTEGER NOT NULL, `feedId` INTEGER NOT NULL, `status` TEXT NOT NULL, `hash` TEXT NOT NULL, `title` TEXT NOT NULL, `url` TEXT NOT NULL, `commentsUrl` TEXT NOT NULL, `publishedAt` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `content` TEXT NOT NULL, `author` TEXT NOT NULL, `shareCode` TEXT NOT NULL, `starred` INTEGER NOT NULL, `readingTime` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "feedId",
"columnName": "feedId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "status",
"columnName": "status",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "hash",
"columnName": "hash",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "commentsUrl",
"columnName": "commentsUrl",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "publishedAt",
"columnName": "publishedAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "content",
"columnName": "content",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "author",
"columnName": "author",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "shareCode",
"columnName": "shareCode",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "starred",
"columnName": "starred",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "readingTime",
"columnName": "readingTime",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "Category",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `title` TEXT NOT NULL, `userId` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "Icon",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`feedId` INTEGER NOT NULL, `iconId` INTEGER NOT NULL, PRIMARY KEY(`iconId`))",
"fields": [
{
"fieldPath": "feedId",
"columnName": "feedId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "iconId",
"columnName": "iconId",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"iconId"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "User",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `username` TEXT NOT NULL, `admin` INTEGER NOT NULL, `theme` TEXT NOT NULL, `language` TEXT NOT NULL, `timezone` TEXT NOT NULL, `entrySortingDirection` TEXT NOT NULL, `stylesheet` TEXT NOT NULL, `googleId` TEXT NOT NULL, `openidConnectId` TEXT NOT NULL, `entriesPerPage` INTEGER NOT NULL, `keyboardShortcuts` INTEGER NOT NULL, `showReadingTime` INTEGER NOT NULL, `entrySwipe` INTEGER NOT NULL, `lastLoginAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "username",
"columnName": "username",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "admin",
"columnName": "admin",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "theme",
"columnName": "theme",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "language",
"columnName": "language",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "timezone",
"columnName": "timezone",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "entrySortingDirection",
"columnName": "entrySortingDirection",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "stylesheet",
"columnName": "stylesheet",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "googleId",
"columnName": "googleId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "openidConnectId",
"columnName": "openidConnectId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "entriesPerPage",
"columnName": "entriesPerPage",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "keyboardShortcuts",
"columnName": "keyboardShortcuts",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "showReadingTime",
"columnName": "showReadingTime",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "entrySwipe",
"columnName": "entrySwipe",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastLoginAt",
"columnName": "lastLoginAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'a3ba65cc1674ea74c169d8f134ff3c42')"
]
}
}

View file

@ -1,14 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="com.wbrawner.nanoflux">
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.INTERNET"/>
<application
android:name=".NanofluxApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Nanoflux">
android:theme="@style/Theme.Nanoflux"
android:usesCleartextTraffic="true"
tools:targetApi="m">
<activity
android:name=".MainActivity"
android:exported="true"
@ -20,6 +25,10 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.work.impl.WorkManagerInitializer"
android:authorities="${applicationId}.workmanager-init"
tools:node="remove" />
</application>
</manifest>

View file

@ -3,21 +3,37 @@ package com.wbrawner.nanoflux
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.foundation.text.BasicText
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.tooling.preview.Preview
import com.wbrawner.nanoflux.data.model.Entry
import com.wbrawner.nanoflux.data.model.Feed
import com.wbrawner.nanoflux.data.viewmodel.AuthViewModel
import com.wbrawner.nanoflux.ui.MainScreen
import com.wbrawner.nanoflux.ui.auth.AuthScreen
import com.wbrawner.nanoflux.ui.theme.NanofluxTheme
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
val authViewModel: AuthViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
NanofluxTheme {
// A surface container using the 'background' color from the theme
Surface(color = MaterialTheme.colors.background) {
Greeting("Android")
val authenticated = authViewModel.state.collectAsState()
NanofluxApp {
if (authenticated.value is AuthViewModel.AuthState.Authenticated) {
MainScreen(authViewModel)
} else {
AuthScreen(authViewModel)
}
}
}
@ -25,14 +41,31 @@ class MainActivity : ComponentActivity() {
}
@Composable
fun Greeting(name: String) {
Text(text = "Hello $name!")
fun NanofluxApp(content: @Composable () -> Unit) {
NanofluxTheme {
Surface(color = MaterialTheme.colors.background) {
content()
}
}
}
@Preview(showBackground = true)
@Composable
fun DefaultPreview() {
NanofluxTheme {
Greeting("Android")
fun AppPreview() {
NanofluxApp {
}
}
@Composable
fun EntryListItem(
entry: Entry,
feed: Feed,
onEntryItemClicked: (entry: Entry) -> Unit,
onFeedClicked: (feed: Feed) -> Unit,
onToggleReadClicked: (entry: Entry) -> Unit,
onStarClicked: (entry: Entry) -> Unit,
onExternalLinkClicked: (entry: Entry) -> Unit
) {
}

View file

@ -0,0 +1,19 @@
package com.wbrawner.nanoflux
import android.app.Application
import androidx.hilt.work.HiltWorkerFactory
import androidx.room.Room
import androidx.work.Configuration
import com.wbrawner.nanoflux.data.NanofluxDatabase
import dagger.hilt.android.HiltAndroidApp
import javax.inject.Inject
@HiltAndroidApp
class NanofluxApplication : Application(), Configuration.Provider {
@Inject
lateinit var workerFactory: HiltWorkerFactory
override fun getWorkManagerConfiguration(): Configuration = Configuration.Builder()
.setWorkerFactory(workerFactory)
.build()
}

View file

@ -0,0 +1,36 @@
package com.wbrawner.nanoflux
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.IBinder
import androidx.hilt.work.HiltWorker
import androidx.work.Worker
import androidx.work.WorkerParameters
import com.wbrawner.nanoflux.data.MinifluxApiService
import com.wbrawner.nanoflux.data.repository.EntryRepository
import com.wbrawner.nanoflux.data.repository.FeedRepository
import com.wbrawner.nanoflux.data.repository.UserRepository
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import kotlinx.coroutines.runBlocking
import javax.inject.Inject
@HiltWorker
class SyncWorker @AssistedInject constructor(
@Assisted context: Context,
@Assisted workerParams: WorkerParameters
) : Worker(context, workerParams) {
@Inject
lateinit var entryRepository: EntryRepository
@Inject
lateinit var feedRepository: FeedRepository
override fun doWork(): Result {
runBlocking {
feedRepository.getAll()
entryRepository.getAll()
}
return Result.success()
}
}

View file

@ -0,0 +1,147 @@
package com.wbrawner.nanoflux.data
import com.wbrawner.nanoflux.data.model.Category
import com.wbrawner.nanoflux.data.model.Entry
import com.wbrawner.nanoflux.data.model.Feed
import com.wbrawner.nanoflux.data.model.User
import okhttp3.RequestBody
import okhttp3.Response
import okhttp3.ResponseBody
import retrofit2.http.*
interface MinifluxApiService {
// @POST("discover")
// suspend fun discoverSubscriptions(@Body url: String): DiscoverResponse
@GET("feeds")
suspend fun getFeeds(): List<Feed>
@GET("categories/{categoryId}/feeds")
suspend fun getCategoryFeeds(@Path("categoryId") categoryId: Long): List<Feed>
@GET("feeds/{id}")
suspend fun getFeed(@Path("id") id: Long): Feed
@GET("feeds/{feedId}/icon")
suspend fun getFeedIcon(@Path("feedId") feedId: Long): Feed.Icon
// @POST("feeds")
// suspend fun createFeed(feed: FeedRequest): CreateFeedResponse
// @PUT("feeds/{id}")
// suspend fun updateFeed(@Path("id") id: Long, @Body request: FeedRequest): Feed
@PUT("feeds/refresh")
suspend fun refreshFeeds(): Response
@PUT("feeds/{id}/refresh")
suspend fun refreshFeed(@Path("id") id: Long): Response
@DELETE("feeds/{id}")
suspend fun deleteFeed(@Path("id") id: Long): Response
@GET("feeds/{feedId}/entries")
suspend fun getFeedEntries(
@Path("feedId") feedId: Long,
@Query("status") status: List<Entry.Status>? = null,
@Query("offset") offset: Long? = null,
@Query("limit") limit: Long? = null,
@Query("order") order: Entry.Order? = null,
@Query("direction") direction: Entry.SortDirection? = null,
@Query("before") before: Long? = null,
@Query("after") after: Long? = null,
@Query("before_entry_id") beforeEntryId: Long? = null,
@Query("after_entry_id") afterEntryId: Long? = null,
@Query("starred") starred: Boolean? = null,
@Query("search") search: String? = null,
@Query("category_id") categoryId: Long? = null,
): List<Entry>
@GET("feeds/{feedId}/entries/{entryId}")
suspend fun getFeedEntry(@Path("feedId") feedId: Long, @Path("entryId") entryId: Long): Entry
@GET("entries")
suspend fun getEntries(
@Query("status") status: List<Entry.Status>? = null,
@Query("offset") offset: Long? = null,
@Query("limit") limit: Long? = null,
@Query("order") order: Entry.Order? = null,
@Query("direction") direction: Entry.SortDirection? = null,
@Query("before") before: Long? = null,
@Query("after") after: Long? = null,
@Query("before_entry_id") beforeEntryId: Long? = null,
@Query("after_entry_id") afterEntryId: Long? = null,
@Query("starred") starred: Boolean? = null,
@Query("search") search: String? = null,
@Query("category_id") categoryId: Long? = null,
): List<Entry>
@GET("entries/{entryId}")
suspend fun getEntry(@Path("entryId") entryId: Long): Entry
@PUT("feeds/{id}/mark-all-as-read")
suspend fun markFeedEntriesAsRead(@Path("id") id: Long): Response
//{
// "entry_ids": [1234, 4567],
// "status": "read"
//}
// @PUT("entries")
// suspend fun updateEntries(request: UpdateEntriesRequest): Response
@PUT("entries/{id}/bookmark")
suspend fun toggleEntryBookmark(@Path("id") id: Long): Response
@GET("categories")
suspend fun getCategories(): List<Category>
@POST("categories")
suspend fun createCategory(@Body title: String): Category
@PUT("categories/{id}")
suspend fun updateCategory(@Path("id") id: Long, @Body title: String): Category
@DELETE("categories/{id}")
suspend fun deleteCategory(@Path("id") id: Long): Response
@PUT("categories/{id}/mark-all-as-read")
suspend fun markCategoryEntriesAsRead(@Path("id") id: Long): Response
@GET("export")
@Streaming
suspend fun opmlExport(): retrofit2.Response<ResponseBody>
@POST("import")
@Streaming
suspend fun opmlImport(@Body opml: RequestBody): retrofit2.Response<ResponseBody>
// @POST("users")
// suspend fun createUser(@Body user: UserRequest): User
@PUT("users/{id}")
suspend fun updateUser(@Path("id") id: Long, @Body user: User): User
@GET("me")
suspend fun getCurrentUser(): User
@GET("users/{id}")
suspend fun getUser(@Path("id") id: Long): User
@GET("users/{name}")
suspend fun getUserByName(@Path("name") name: String): User
@GET("users")
suspend fun getUses(): List<User>
@DELETE("users/{id}")
suspend fun deleteUser(@Path("id") id: Long)
@PUT("users/{id}/mark-all-as-read")
suspend fun markUserEntriesAsRead(@Path("id") id: Long): Response
@GET("/healthcheck")
suspend fun healthcheck(): retrofit2.Response<String>
@GET("/version")
suspend fun version(): retrofit2.Response<String>
}

View file

@ -0,0 +1,34 @@
package com.wbrawner.nanoflux.data
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverter
import androidx.room.TypeConverters
import com.wbrawner.nanoflux.data.dao.*
import com.wbrawner.nanoflux.data.model.Category
import com.wbrawner.nanoflux.data.model.Entry
import com.wbrawner.nanoflux.data.model.Feed
import com.wbrawner.nanoflux.data.model.User
import java.util.*
@Database(
entities = [Feed::class, Entry::class, Category::class, Feed.Icon::class, User::class],
version = 1,
exportSchema = true,
)
@TypeConverters(DateTypeConverter::class)
abstract class NanofluxDatabase: RoomDatabase() {
abstract fun feedDao(): FeedDao
abstract fun entryDao(): EntryDao
abstract fun categoryDao(): CategoryDao
abstract fun iconDao(): IconDao
abstract fun userDao(): UserDao
}
class DateTypeConverter {
@TypeConverter
fun fromTimestamp(timestamp: Long) = Date(timestamp)
@TypeConverter
fun toTimestamp(date: Date) = date.time
}

View file

@ -0,0 +1,24 @@
package com.wbrawner.nanoflux.data.dao
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.Query
import com.wbrawner.nanoflux.data.model.Category
import com.wbrawner.nanoflux.data.model.Feed
import com.wbrawner.nanoflux.data.model.FeedCategoryIcon
@Dao
interface CategoryDao {
@Query("SELECT * FROM Category")
fun getAll(): List<Category>
@Query("SELECT * FROM Category WHERE id in (:ids)")
fun getAllByIds(vararg ids: Long): List<Category>
@Insert
fun insertAll(vararg categories: Category)
@Delete
fun delete(category: Category)
}

View file

@ -0,0 +1,34 @@
package com.wbrawner.nanoflux.data.dao
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.Query
import com.wbrawner.nanoflux.data.model.Entry
import com.wbrawner.nanoflux.data.model.Feed
import com.wbrawner.nanoflux.data.model.FeedCategoryIcon
import kotlinx.coroutines.flow.Flow
@Dao
interface EntryDao {
@Query("SELECT * FROM Entry")
fun getAll(): Flow<List<Entry>>
@Query("SELECT * FROM Entry WHERE status = \"UNREAD\"")
fun getAllUnread(): Flow<List<Entry>>
@Query("SELECT * FROM Entry WHERE id in (:ids)")
fun getAllByIds(vararg ids: Long): List<Entry>
@Query("SELECT * FROM Entry WHERE feedId in (:ids)")
fun getAllByFeedIds(vararg ids: Long): Flow<List<Entry>>
@Query("SELECT * FROM Entry WHERE status = \"UNREAD\" AND feedId in (:ids)")
fun getAllUnreadByFeedIds(vararg ids: Long): Flow<List<Entry>>
@Insert
fun insertAll(entries: List<Entry>)
@Delete
fun delete(entry: Entry)
}

View file

@ -0,0 +1,23 @@
package com.wbrawner.nanoflux.data.dao
import androidx.room.*
import com.wbrawner.nanoflux.data.model.Feed
import com.wbrawner.nanoflux.data.model.FeedCategoryIcon
import kotlinx.coroutines.flow.Flow
@Dao
interface FeedDao {
@Transaction
@Query("SELECT * FROM Feed")
fun getAll(): Flow<List<FeedCategoryIcon>>
@Transaction
@Query("SELECT * FROM Feed WHERE id in (:ids)")
fun getAllByIds(vararg ids: Long): List<FeedCategoryIcon>
@Insert
fun insertAll(feeds: List<Feed>)
@Delete
fun delete(feed: Feed)
}

View file

@ -0,0 +1,22 @@
package com.wbrawner.nanoflux.data.dao
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.Query
import com.wbrawner.nanoflux.data.model.Feed
@Dao
interface IconDao {
@Query("SELECT * FROM Icon")
fun getAll(): List<Feed.Icon>
@Query("SELECT * FROM Icon WHERE iconId in (:ids)")
fun getAllByIds(vararg ids: Long): List<Feed.Icon>
@Insert
fun insertAll(vararg icons: Feed.Icon)
@Delete
fun delete(icon: Feed.Icon)
}

View file

@ -0,0 +1,26 @@
package com.wbrawner.nanoflux.data.dao
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.Query
import com.wbrawner.nanoflux.data.model.Feed
import com.wbrawner.nanoflux.data.model.User
@Dao
interface UserDao {
@Query("SELECT * FROM User")
suspend fun getAll(): List<User>
@Query("SELECT * FROM User WHERE id in (:ids)")
suspend fun getAllByIds(vararg ids: Long): List<User>
@Insert
suspend fun insertAll(vararg users: User)
@Delete
suspend fun delete(user: User)
@Query("DELETE FROM User")
suspend fun deleteAll()
}

View file

@ -0,0 +1,18 @@
package com.wbrawner.nanoflux.data.model
import androidx.room.Entity
import androidx.room.PrimaryKey
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@Entity
@JsonClass(generateAdapter = true)
data class Category(
@PrimaryKey
@Json(name = "id")
val id: Long,
@Json(name = "title")
val title: String,
@Json(name = "user_id")
val userId: Long,
)

View file

@ -0,0 +1,62 @@
package com.wbrawner.nanoflux.data.model
import androidx.room.Entity
import androidx.room.PrimaryKey
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import java.util.*
@Entity
@JsonClass(generateAdapter = true)
data class Entry(
@Json(name = "id")
@PrimaryKey
val id: Long,
@Json(name = "user_id")
val userId: Long,
@Json(name = "feed_id")
val feedId: Long,
@Json(name = "status")
val status: Status,
@Json(name = "hash")
val hash: String,
@Json(name = "title")
val title: String,
@Json(name = "url")
val url: String,
@Json(name = "comments_url")
val commentsUrl: String,
@Json(name = "published_at")
val publishedAt: Date,
@Json(name = "created_at")
val createdAt: Date,
@Json(name = "content")
val content: String,
@Json(name = "author")
val author: String,
@Json(name = "share_code")
val shareCode: String,
@Json(name = "starred")
val starred: Boolean,
@Json(name = "reading_time")
val readingTime: Int,
) {
enum class Status {
READ,
UNREAD,
REMOVED
}
enum class Order {
ID,
STATUS,
PUBLISHED_AT,
CATEGORY_TITLE,
CATEGORY_ID
}
enum class SortDirection {
ASC,
DESC
}
}

View file

@ -0,0 +1,63 @@
package com.wbrawner.nanoflux.data.model
import androidx.room.Embedded
import androidx.room.Entity
import androidx.room.PrimaryKey
import androidx.room.Relation
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import java.util.*
@Entity
data class Feed(
@PrimaryKey
val id: Long,
val userId: Long,
val title: String,
val siteUrl: String,
val feedUrl: String,
val checkedAt: Date,
val etagHeader: String,
val lastModifiedHeader: String,
val parsingErrorMessage: String,
val parsingErrorCount: Int,
val scraperRules: String,
val rewriteRules: String,
val crawler: Boolean,
val blocklistRules: String,
val keeplistRules: String,
val userAgent: String,
val username: String,
val password: String,
val disabled: Boolean,
val ignoreHttpCache: Boolean,
val fetchViaProxy: Boolean,
val categoryId: Long,
val iconId: Long?
) {
@Entity
@JsonClass(generateAdapter = true)
data class Icon(
@Json(name = "feed_id")
val feedId: Long,
@PrimaryKey
@Json(name = "icon_id")
val iconId: Long
)
}
data class FeedCategoryIcon(
@Embedded
val feed: Feed,
@Relation(
parentColumn = "id",
entityColumn = "feedId"
)
val icon: Feed.Icon?,
@Relation(
parentColumn = "categoryId",
entityColumn = "id",
entity = Category::class
)
val category: Category
)

View file

@ -0,0 +1,43 @@
package com.wbrawner.nanoflux.data.model
import androidx.room.Entity
import androidx.room.PrimaryKey
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import java.util.*
@Entity
@JsonClass(generateAdapter = true)
data class User(
@Json(name = "id")
@PrimaryKey
val id: Long,
@Json(name = "username")
val username: String,
@Json(name = "is_admin")
val admin: Boolean,
@Json(name = "theme")
val theme: String,
@Json(name = "language")
val language: String,
@Json(name = "timezone")
val timezone: String,
@Json(name = "entry_sorting_direction")
val entrySortingDirection: String,
@Json(name = "stylesheet")
val stylesheet: String,
@Json(name = "google_id")
val googleId: String,
@Json(name = "openid_connect_id")
val openidConnectId: String,
@Json(name = "entries_per_page")
val entriesPerPage: Int,
@Json(name = "keyboard_shortcuts")
val keyboardShortcuts: Boolean,
@Json(name = "show_reading_time")
val showReadingTime: Boolean,
@Json(name = "entry_swipe")
val entrySwipe: Boolean,
@Json(name = "last_login_at")
val lastLoginAt: Date,
)

View file

@ -0,0 +1,27 @@
package com.wbrawner.nanoflux.data.repository
import com.wbrawner.nanoflux.data.MinifluxApiService
import com.wbrawner.nanoflux.data.dao.EntryDao
import com.wbrawner.nanoflux.data.dao.FeedDao
import com.wbrawner.nanoflux.data.model.Entry
import com.wbrawner.nanoflux.data.model.FeedCategoryIcon
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
class EntryRepository @Inject constructor(
private val apiService: MinifluxApiService,
private val entryDao: EntryDao,
private val logger: Timber.Tree
) {
private val entries: Flow<List<Entry>> = entryDao.getAll()
fun getAll(): Flow<List<Entry>> {
GlobalScope.launch {
entryDao.insertAll(apiService.getEntries())
}
return entries
}
}

View file

@ -0,0 +1,25 @@
package com.wbrawner.nanoflux.data.repository
import com.wbrawner.nanoflux.data.MinifluxApiService
import com.wbrawner.nanoflux.data.dao.FeedDao
import com.wbrawner.nanoflux.data.model.FeedCategoryIcon
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
class FeedRepository @Inject constructor(
private val apiService: MinifluxApiService,
private val feedDao: FeedDao,
private val logger: Timber.Tree
) {
private val feeds: Flow<List<FeedCategoryIcon>> = feedDao.getAll()
fun getAll(): Flow<List<FeedCategoryIcon>> {
GlobalScope.launch {
feedDao.insertAll(apiService.getFeeds())
}
return feeds
}
}

View file

@ -0,0 +1,101 @@
package com.wbrawner.nanoflux.data.repository
import android.content.SharedPreferences
import android.util.Base64
import androidx.core.content.edit
import com.wbrawner.nanoflux.data.MinifluxApiService
import com.wbrawner.nanoflux.data.dao.UserDao
import com.wbrawner.nanoflux.data.model.User
import com.wbrawner.nanoflux.di.PREF_KEY_BASE_URL
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
const val PREF_KEY_AUTH_TOKEN = "authToken"
const val PREF_KEY_CURRENT_USER = "currentUser"
class UserRepository @Inject constructor(
private val sharedPreferences: SharedPreferences,
private val apiService: MinifluxApiService,
private val userDao: UserDao,
private val logger: Timber.Tree
) {
private val _currentUser: MutableSharedFlow<User> = MutableSharedFlow(replay = 1)
init {
GlobalScope.launch(Dispatchers.IO) {
try {
getCurrentUser()
} catch (e: Exception) {
logger.e(e, "No previously logged in user")
}
}
}
suspend fun login(server: String, username: String, password: String) {
var correctedServer = if (server.startsWith("http")) {
server
} else {
"http://$server"
}
if (!correctedServer.endsWith("/v1")) {
correctedServer = if (correctedServer.endsWith("/")) {
"${correctedServer}v1"
} else {
"$correctedServer/v1"
}
}
val credentials = Base64.encode(
"$username:$password".encodeToByteArray(),
Base64.DEFAULT or Base64.NO_WRAP
)
.decodeToString()
sharedPreferences.edit {
putString(PREF_KEY_BASE_URL, correctedServer)
putString(PREF_KEY_AUTH_TOKEN, "Basic $credentials")
}
try {
val user = apiService.getCurrentUser()
_currentUser.emit(user)
userDao.insertAll(user)
sharedPreferences.edit {
putLong(PREF_KEY_CURRENT_USER, user.id)
}
} catch (e: Exception) {
sharedPreferences.edit {
remove(PREF_KEY_BASE_URL)
remove(PREF_KEY_AUTH_TOKEN)
remove(PREF_KEY_CURRENT_USER)
}
throw e
}
}
suspend fun logout() {
userDao.deleteAll()
sharedPreferences.edit {
remove(PREF_KEY_BASE_URL)
remove(PREF_KEY_AUTH_TOKEN)
remove(PREF_KEY_CURRENT_USER)
}
}
suspend fun getCurrentUser(): User {
return _currentUser.replayCache.firstOrNull()
?: sharedPreferences.getLong(PREF_KEY_CURRENT_USER, -1L).let {
if (it == -1L) {
null
} else {
userDao.getAllByIds(it).firstOrNull()
}
}
?: apiService.getCurrentUser().also {
userDao.insertAll(it)
_currentUser.emit(it)
}
}
}

View file

@ -0,0 +1,95 @@
package com.wbrawner.nanoflux.data.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.wbrawner.nanoflux.data.model.User
import com.wbrawner.nanoflux.data.repository.UserRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@HiltViewModel
class AuthViewModel @Inject constructor(
private val userRepository: UserRepository,
private val logger: Timber.Tree
) : ViewModel() {
private val _state = MutableStateFlow<AuthState>(AuthState.Loading)
val state = _state.asStateFlow()
init {
viewModelScope.launch {
try {
_state.emit(AuthState.Authenticated(userRepository.getCurrentUser()))
} catch (e: Exception) {
logger.e(e, "Unable to login user")
_state.emit(AuthState.Unauthenticated())
}
}
}
fun login(server: String, username: String, password: String) {
viewModelScope.launch {
if (server.isBlank()) {
_state.emit(
AuthState.Unauthenticated(
server,
username,
password,
"Please enter a valid server URL"
)
)
return@launch
}
if (username.isBlank()) {
_state.emit(
AuthState.Unauthenticated(
server,
username,
password,
"Please enter a valid username"
)
)
return@launch
}
if (password.isBlank()) {
_state.emit(
AuthState.Unauthenticated(
server,
username,
password,
"Please enter a valid password"
)
)
return@launch
}
_state.emit(AuthState.Loading)
try {
userRepository.login(server.trim(), username.trim(), password.trim())
_state.emit(AuthState.Authenticated(userRepository.getCurrentUser()))
} catch (e: Exception) {
logger.e(e, "Login failed")
_state.emit(AuthState.Unauthenticated(server, username, password, e.message))
}
}
}
fun logout() {
viewModelScope.launch {
userRepository.logout()
_state.emit(AuthState.Unauthenticated("", "", ""))
}
}
sealed class AuthState {
object Loading : AuthState()
class Authenticated(val user: User) : AuthState()
class Unauthenticated(
val server: String = "",
val username: String = "",
val password: String = "",
val errorMessage: String? = null
) : AuthState()
}
}

View file

@ -0,0 +1,66 @@
package com.wbrawner.nanoflux.di
import android.content.SharedPreferences
import androidx.preference.Preference
import androidx.preference.PreferenceDataStore
import com.squareup.moshi.Moshi
import com.squareup.moshi.adapters.Rfc3339DateJsonAdapter
import com.wbrawner.nanoflux.BuildConfig
import com.wbrawner.nanoflux.data.MinifluxApiService
import com.wbrawner.nanoflux.data.repository.PREF_KEY_AUTH_TOKEN
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import java.util.*
const val PREF_KEY_BASE_URL = "BASE_URL"
private const val DEFAULT_BASE_URL = "https://base_url"
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
fun providesOkHttpClient(prefs: SharedPreferences): OkHttpClient {
val client = OkHttpClient.Builder()
.addInterceptor {
val old = it.request()
val newUrl = prefs.getString(PREF_KEY_BASE_URL, null)
if (old.url.host != "base_url" || newUrl == null) {
return@addInterceptor it.proceed(it.request())
}
val new = old.newBuilder()
val url = old.url.toString().replace(DEFAULT_BASE_URL, newUrl).toHttpUrl()
new.header("Authorization", prefs.getString(PREF_KEY_AUTH_TOKEN, null) ?: "")
it.proceed(new.url(url).build())
}
if (BuildConfig.DEBUG) {
client.addInterceptor(HttpLoggingInterceptor(logger = HttpLoggingInterceptor.Logger.DEFAULT).also {
it.setLevel(HttpLoggingInterceptor.Level.BODY)
})
}
return client.build()
}
@Provides
fun providesMoshi(): Moshi = Moshi.Builder()
.add(Date::class.java, Rfc3339DateJsonAdapter())
.build()
@Provides
fun providesRetrofit(moshi: Moshi, okHttpClient: OkHttpClient) = Retrofit.Builder()
.baseUrl(DEFAULT_BASE_URL)
.client(okHttpClient)
.addConverterFactory(MoshiConverterFactory.create(moshi))
.build()
@Provides
fun provideMinifluxApiService(retrofit: Retrofit) =
retrofit.create(MinifluxApiService::class.java)
}

View file

@ -0,0 +1,47 @@
package com.wbrawner.nanoflux.di
import android.content.Context
import android.content.SharedPreferences
import androidx.preference.PreferenceManager
import androidx.room.Room
import com.wbrawner.nanoflux.data.NanofluxDatabase
import com.wbrawner.nanoflux.data.dao.*
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object StorageModule {
@Provides
fun providesSharedPreferences(@ApplicationContext context: Context): SharedPreferences =
PreferenceManager.getDefaultSharedPreferences(context)
@Provides
@Singleton
fun providesNanofluxDatabase(@ApplicationContext context: Context): NanofluxDatabase =
Room.databaseBuilder(context, NanofluxDatabase::class.java, "nanoflux").build()
@Provides
@Singleton
fun providesCategoryDao(nanofluxDatabase: NanofluxDatabase): CategoryDao = nanofluxDatabase.categoryDao()
@Provides
@Singleton
fun providesEntryDao(nanofluxDatabase: NanofluxDatabase): EntryDao = nanofluxDatabase.entryDao()
@Provides
@Singleton
fun providesFeedDao(nanofluxDatabase: NanofluxDatabase): FeedDao = nanofluxDatabase.feedDao()
@Provides
@Singleton
fun providesIconDao(nanofluxDatabase: NanofluxDatabase): IconDao = nanofluxDatabase.iconDao()
@Provides
@Singleton
fun providesUserDao(nanofluxDatabase: NanofluxDatabase): UserDao = nanofluxDatabase.userDao()
}

View file

@ -0,0 +1,20 @@
package com.wbrawner.nanoflux.di
import com.wbrawner.nanoflux.BuildConfig
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import timber.log.Timber
@Module
@InstallIn(SingletonComponent::class)
object UtilityModule {
@Provides
fun providesLogger(): Timber.Tree {
if (BuildConfig.DEBUG) {
Timber.plant(Timber.DebugTree())
}
return Timber.asTree()
}
}

View file

@ -0,0 +1,81 @@
package com.wbrawner.nanoflux.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.BasicText
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Menu
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.wbrawner.nanoflux.NanofluxApp
import com.wbrawner.nanoflux.data.viewmodel.AuthViewModel
import kotlinx.coroutines.launch
@Composable
fun MainScreen(authViewModel: AuthViewModel) {
MainScaffold(
authViewModel::logout
)
}
@Composable
fun MainScaffold(
onLogoutClicked: () -> Unit
) {
val scaffoldState = rememberScaffoldState()
val coroutineScope = rememberCoroutineScope()
Scaffold(
scaffoldState = scaffoldState,
topBar = {
TopAppBar(navigationIcon = {
IconButton(onClick = { coroutineScope.launch { scaffoldState.drawerState.open() } }) {
Icon(
imageVector = Icons.Default.Menu,
contentDescription = "Menu"
)
}
}, title = { Text(text = "Unread") })
},
drawerContent = {
TextButton(onClick = onLogoutClicked, modifier = Modifier.fillMaxWidth()) {
Row(
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
// Image(
// imageVector = icon,
// contentDescription = null, // decorative
// colorFilter = ColorFilter.tint(textIconColor),
// alpha = imageAlpha
// )
// Spacer(Modifier.width(16.dp))
Text(
text = "Logout",
modifier = Modifier.padding(8.dp),
style = MaterialTheme.typography.body2
)
}
}
}
) {
}
}
@Composable
@Preview
fun MainScaffold_Preview() {
NanofluxApp {
MainScaffold {}
}
}

View file

@ -0,0 +1,202 @@
package com.wbrawner.nanoflux.ui.auth
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.*
import androidx.compose.ui.input.key.*
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.wbrawner.nanoflux.NanofluxApp
import com.wbrawner.nanoflux.data.viewmodel.AuthViewModel
import com.wbrawner.nanoflux.ui.theme.NanofluxTheme
import timber.log.Timber
@Composable
fun AuthScreen(authViewModel: AuthViewModel) {
val vmState = authViewModel.state.collectAsState()
val state = vmState.value
if (state is AuthViewModel.AuthState.Loading) {
CircularProgressIndicator()
} else if (state is AuthViewModel.AuthState.Unauthenticated) {
AuthForm(
state.server,
state.username,
state.password,
authViewModel::login,
state.errorMessage
)
}
}
@Composable
fun AuthForm(
initialServer: String,
initialUsername: String,
initialPassword: String,
onLoginClicked: (String, String, String) -> Unit,
error: String? = null,
) {
val scrollState = rememberScrollState()
val serverFocus = remember { FocusRequester() }
val usernameFocus = remember { FocusRequester() }
val passwordFocus = remember { FocusRequester() }
val (server: String, setServer: (String) -> Unit) = remember { mutableStateOf(initialServer) }
val (username: String, setUsername: (String) -> Unit) = remember {
mutableStateOf(
initialUsername
)
}
val (password: String, setPassword: (String) -> Unit) = remember {
mutableStateOf(
initialPassword
)
}
fun handleKeyEvent(
previousFocusRequester: FocusRequester?,
nextFocusRequester: FocusRequester?
): (KeyEvent) -> Boolean = {
when (it.key) {
Key.Tab -> {
if (it.isShiftPressed) {
previousFocusRequester?.requestFocus()
Timber.d("Shift+Tab pressed")
} else {
nextFocusRequester?.requestFocus()
Timber.d("Tab pressed")
}
true
}
Key.Enter -> {
Timber.d("Enter pressed")
onLoginClicked(server, username, password)
true
}
else -> false
}
}
Column(
modifier = Modifier
.verticalScroll(state = scrollState)
.fillMaxWidth()
.fillMaxHeight(),
verticalArrangement = Arrangement.Center
) {
val commonModifier = Modifier
.fillMaxWidth()
.padding(8.dp)
if (!error.isNullOrBlank()) {
BasicText(
text = error,
style = TextStyle.Default.copy(
color = MaterialTheme.colors.error,
textAlign = TextAlign.Center
),
modifier = commonModifier
)
}
OutlinedTextField(
value = server,
onValueChange = setServer,
label = {
BasicText(
text = "Miniflux Server URL",
style = TextStyle.Default.copy(color = MaterialTheme.colors.onSurface)
)
},
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Uri,
imeAction = ImeAction.Next
),
keyboardActions = KeyboardActions(onNext = { usernameFocus.requestFocus() }),
modifier = commonModifier
.focusRequester(serverFocus)
.onPreviewKeyEvent(handleKeyEvent(null, usernameFocus)),
maxLines = 1
)
OutlinedTextField(
value = username,
onValueChange = setUsername,
label = {
BasicText(
text = "Username",
style = TextStyle.Default.copy(color = MaterialTheme.colors.onSurface)
)
},
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Email,
imeAction = ImeAction.Next
),
keyboardActions = KeyboardActions(onNext = { passwordFocus.requestFocus() }),
modifier = commonModifier
.focusRequester(usernameFocus)
.onPreviewKeyEvent(handleKeyEvent(serverFocus, passwordFocus)),
maxLines = 1
)
OutlinedTextField(
value = password,
onValueChange = setPassword,
label = {
BasicText(
text = "Password",
style = TextStyle.Default.copy(color = MaterialTheme.colors.onSurface)
)
},
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Go
),
keyboardActions = KeyboardActions(onGo = {
onLoginClicked(server, username, password)
}),
modifier = commonModifier
.focusRequester(passwordFocus)
.onPreviewKeyEvent(handleKeyEvent(usernameFocus, null)),
visualTransformation = PasswordVisualTransformation(),
maxLines = 1
)
Button(
onClick = { onLoginClicked(server, username, password) },
modifier = commonModifier,
) {
BasicText(
"Login",
style = TextStyle.Default.copy(color = MaterialTheme.colors.onPrimary)
)
}
}
}
@Composable
@Preview
fun Preview_AuthScreen() {
NanofluxApp {
AuthForm("https://example.org", "myuser", "mypass", { _, _, _ -> }, "Invalid password")
}
}
@Composable
@Preview
fun Preview_Night_AuthScreen() {
NanofluxTheme(darkTheme = true) {
Surface(color = MaterialTheme.colors.surface) {
AuthForm("https://example.org", "myuser", "mypass", { _, _, _ -> }, "Invalid password")
}
}
}

View file

@ -4,13 +4,12 @@ buildscript {
google()
mavenCentral()
}
val kotlinVersion by extra<String>("1.5.0")
val hiltVersion by extra("2.35")
val kotlinVersion by extra("1.4.32")
dependencies {
classpath("com.android.tools.build:gradle:7.0.0-alpha15")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion")
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle.kts files
classpath("com.google.dagger:hilt-android-gradle-plugin:$hiltVersion")
}
}

View file

@ -3,10 +3,15 @@ androidx-core = "1.3.2"
androidx-appcompat = "1.2.0"
compose = "1.0.0-beta05"
espresso = "3.3.0"
kotlin = "1.5.0"
hilt-android = "2.35"
hilt-work = "1.0.0"
kotlin = "1.4.32"
material = "1.3.0"
moshi = "1.12.0"
okhttp = "4.9.1"
retrofit = "2.9.0"
room = "2.3.0"
work = "2.5.0"
[libraries]
androidx-core = { module = "androidx.core:core-ktx", version.ref = "androidx-core" }
@ -17,17 +22,31 @@ compose-material = { module = "androidx.compose.material:material", version.ref
compose-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose" }
compose-activity = { module = "androidx.activity:activity-compose", version = "1.3.0-alpha07" }
compose-test = { module = "androidx.compose.ui:ui-test-junit4", version.ref = "compose" }
preference = { module = "androidx.preference:preference-ktx", version = "1.1.1" }
lifecycle = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version = "2.3.1" }
moshi-core = { module = "com.squareup.moshi:moshi", version.ref = "moshi"}
moshi-kapt = { module = "com.squareup.moshi:moshi-kotlin-codegen", version.ref = "moshi"}
hilt-android-core = { module = "com.google.dagger:hilt-android", version.ref = "hilt-android" }
hilt-android-kapt = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt-android" }
hilt-work-core = { module = "androidx.hilt:hilt-work", version.ref = "hilt-work" }
hilt-work-kapt = { module = "androidx.hilt:hilt-compiler", version.ref = "hilt-work" }
moshi-core = { module = "com.squareup.moshi:moshi", version.ref = "moshi" }
moshi-adapters = { module = "com.squareup.moshi:moshi-adapters", version.ref = "moshi" }
moshi-kapt = { module = "com.squareup.moshi:moshi-kotlin-codegen", version.ref = "moshi" }
junit = { module = "junit:junit", version = "4.12" }
kotlin-gradle = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" }
room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
room-kapt = { module = "androidx.room:room-compiler", version.ref = "room" }
room-test = { module = "androidx.room:room-testing", version.ref = "room" }
retrofit-core = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
retrofit-moshi = { module = "com.squareup.retrofit2:converter-moshi", version.ref = "retrofit" }
okhttp-logging = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" }
timber = { module = "com.jakewharton.timber:timber", version = "4.7.1" }
test-ext = { module = "androidx.text.ext:junit", version = "1.1.2" }
espresso = { module = "androidx.test.espresso:espresso-core", version.ref = "espresso" }
work-core = { module = "androidx.work:work-runtime-ktx", version.ref = "work" }
work-test = { module = "androidx.work:work-testing", version.ref = "work" }
[bundles]
compose = ["compose-ui", "compose-material", "compose-tooling", "compose-activity"]
moshi = ["moshi-core", "moshi-adapters"]
networking = ["retrofit-core", "retrofit-moshi", "okhttp-logging"]

View file

@ -4,7 +4,6 @@ dependencyResolutionManagement {
repositories {
google()
mavenCentral()
jcenter() // Warning: this repository is going to shut down soon
}
}
rootProject.name = "Nanoflux"