Compare commits

...

86 commits
kmp ... main

Author SHA1 Message Date
dependabot[bot]
7c2e902b0e Bump com.android.tools.build:gradle from 8.4.1 to 8.4.2
Bumps com.android.tools.build:gradle from 8.4.1 to 8.4.2.

---
updated-dependencies:
- dependency-name: com.android.tools.build:gradle
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-10 16:29:49 -06:00
dependabot[bot]
be492bbaa2 Bump androidx.appcompat:appcompat from 1.6.1 to 1.7.0
Bumps androidx.appcompat:appcompat from 1.6.1 to 1.7.0.

---
updated-dependencies:
- dependency-name: androidx.appcompat:appcompat
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-03 16:41:11 -06:00
dependabot[bot]
fad2b13dee ---
updated-dependencies:
- dependency-name: org.jetbrains.kotlinx:kotlinx-datetime
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-20 16:42:43 -06:00
dependabot[bot]
0872880a49 ---
updated-dependencies:
- dependency-name: com.android.tools.build:gradle
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-20 16:38:45 -06:00
dependabot[bot]
4419e7dada Bump androidx.compose.material:material-icons-extended
Bumps androidx.compose.material:material-icons-extended from 1.6.6 to 1.6.7.

---
updated-dependencies:
- dependency-name: androidx.compose.material:material-icons-extended
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-13 16:38:09 -06:00
dependabot[bot]
318907a113 Bump kotlinx-coroutines from 1.8.0 to 1.8.1
Bumps `kotlinx-coroutines` from 1.8.0 to 1.8.1.

Updates `org.jetbrains.kotlinx:kotlinx-coroutines-android` from 1.8.0 to 1.8.1
- [Release notes](https://github.com/Kotlin/kotlinx.coroutines/releases)
- [Changelog](https://github.com/Kotlin/kotlinx.coroutines/blob/master/CHANGES.md)
- [Commits](https://github.com/Kotlin/kotlinx.coroutines/compare/1.8.0...1.8.1)

Updates `org.jetbrains.kotlinx:kotlinx-coroutines-core` from 1.8.0 to 1.8.1
- [Release notes](https://github.com/Kotlin/kotlinx.coroutines/releases)
- [Changelog](https://github.com/Kotlin/kotlinx.coroutines/blob/master/CHANGES.md)
- [Commits](https://github.com/Kotlin/kotlinx.coroutines/compare/1.8.0...1.8.1)

Updates `org.jetbrains.kotlinx:kotlinx-coroutines-test` from 1.8.0 to 1.8.1
- [Release notes](https://github.com/Kotlin/kotlinx.coroutines/releases)
- [Changelog](https://github.com/Kotlin/kotlinx.coroutines/blob/master/CHANGES.md)
- [Commits](https://github.com/Kotlin/kotlinx.coroutines/compare/1.8.0...1.8.1)

---
updated-dependencies:
- dependency-name: org.jetbrains.kotlinx:kotlinx-coroutines-android
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: org.jetbrains.kotlinx:kotlinx-coroutines-core
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: org.jetbrains.kotlinx:kotlinx-coroutines-test
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-13 16:33:42 -06:00
dependabot[bot]
d73b98d12f Bump ktor from 2.3.10 to 2.3.11
Bumps `ktor` from 2.3.10 to 2.3.11.

Updates `io.ktor:ktor-client-cio` from 2.3.10 to 2.3.11
- [Release notes](https://github.com/ktorio/ktor/releases)
- [Changelog](https://github.com/ktorio/ktor/blob/2.3.11/CHANGELOG.md)
- [Commits](https://github.com/ktorio/ktor/compare/2.3.10...2.3.11)

Updates `io.ktor:ktor-client-ios` from 2.3.10 to 2.3.11
- [Release notes](https://github.com/ktorio/ktor/releases)
- [Changelog](https://github.com/ktorio/ktor/blob/2.3.11/CHANGELOG.md)
- [Commits](https://github.com/ktorio/ktor/compare/2.3.10...2.3.11)

Updates `io.ktor:ktor-client-js` from 2.3.10 to 2.3.11
- [Release notes](https://github.com/ktorio/ktor/releases)
- [Changelog](https://github.com/ktorio/ktor/blob/2.3.11/CHANGELOG.md)
- [Commits](https://github.com/ktorio/ktor/compare/2.3.10...2.3.11)

Updates `io.ktor:ktor-client-android` from 2.3.10 to 2.3.11
- [Release notes](https://github.com/ktorio/ktor/releases)
- [Changelog](https://github.com/ktorio/ktor/blob/2.3.11/CHANGELOG.md)
- [Commits](https://github.com/ktorio/ktor/compare/2.3.10...2.3.11)

Updates `io.ktor:ktor-client-content-negotiation` from 2.3.10 to 2.3.11
- [Release notes](https://github.com/ktorio/ktor/releases)
- [Changelog](https://github.com/ktorio/ktor/blob/2.3.11/CHANGELOG.md)
- [Commits](https://github.com/ktorio/ktor/compare/2.3.10...2.3.11)

Updates `io.ktor:ktor-serialization-kotlinx-json` from 2.3.10 to 2.3.11
- [Release notes](https://github.com/ktorio/ktor/releases)
- [Changelog](https://github.com/ktorio/ktor/blob/2.3.11/CHANGELOG.md)
- [Commits](https://github.com/ktorio/ktor/compare/2.3.10...2.3.11)

Updates `io.ktor:ktor-client-core` from 2.3.10 to 2.3.11
- [Release notes](https://github.com/ktorio/ktor/releases)
- [Changelog](https://github.com/ktorio/ktor/blob/2.3.11/CHANGELOG.md)
- [Commits](https://github.com/ktorio/ktor/compare/2.3.10...2.3.11)

Updates `io.ktor:ktor-client-logging` from 2.3.10 to 2.3.11
- [Release notes](https://github.com/ktorio/ktor/releases)
- [Changelog](https://github.com/ktorio/ktor/blob/2.3.11/CHANGELOG.md)
- [Commits](https://github.com/ktorio/ktor/compare/2.3.10...2.3.11)

---
updated-dependencies:
- dependency-name: io.ktor:ktor-client-cio
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: io.ktor:ktor-client-ios
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: io.ktor:ktor-client-js
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: io.ktor:ktor-client-android
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: io.ktor:ktor-client-content-negotiation
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: io.ktor:ktor-serialization-kotlinx-json
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: io.ktor:ktor-client-core
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: io.ktor:ktor-client-logging
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-13 16:29:20 -06:00
dependabot[bot]
942e38ef90 Bump com.google.android.material:material from 1.11.0 to 1.12.0
Bumps [com.google.android.material:material](https://github.com/material-components/material-components-android) from 1.11.0 to 1.12.0.
- [Release notes](https://github.com/material-components/material-components-android/releases)
- [Commits](https://github.com/material-components/material-components-android/compare/1.11.0...1.12.0)

---
updated-dependencies:
- dependency-name: com.google.android.material:material
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-06 16:37:08 -06:00
dependabot[bot]
2e34cca75a Bump androidx.compose.compiler:compiler from 1.5.12 to 1.5.13
Bumps androidx.compose.compiler:compiler from 1.5.12 to 1.5.13.

---
updated-dependencies:
- dependency-name: androidx.compose.compiler:compiler
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-06 16:33:04 -06:00
dependabot[bot]
921140978d Bump compose from 1.6.6 to 1.6.7
Bumps `compose` from 1.6.6 to 1.6.7.

Updates `androidx.compose.ui:ui-test-junit4` from 1.6.6 to 1.6.7

Updates `androidx.compose.ui:ui-test-manifest` from 1.6.6 to 1.6.7

Updates `androidx.compose.ui:ui-tooling` from 1.6.6 to 1.6.7

Updates `androidx.compose.ui:ui` from 1.6.6 to 1.6.7

---
updated-dependencies:
- dependency-name: androidx.compose.ui:ui-test-junit4
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: androidx.compose.ui:ui-test-manifest
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: androidx.compose.ui:ui-tooling
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: androidx.compose.ui:ui
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-06 16:29:09 -06:00
dependabot[bot]
f7de2b15f8 Bump com.android.tools.build:gradle from 8.3.2 to 8.4.0
Bumps com.android.tools.build:gradle from 8.3.2 to 8.4.0.

---
updated-dependencies:
- dependency-name: com.android.tools.build:gradle
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-06 16:25:07 -06:00
dependabot[bot]
a91c08c226 Bump androidx.core:core-ktx from 1.13.0 to 1.13.1
Bumps androidx.core:core-ktx from 1.13.0 to 1.13.1.

---
updated-dependencies:
- dependency-name: androidx.core:core-ktx
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-05-06 16:21:01 -06:00
dependabot[bot]
001f101f08 Bump androidx.activity:activity-compose from 1.8.2 to 1.9.0
Bumps androidx.activity:activity-compose from 1.8.2 to 1.9.0.

---
updated-dependencies:
- dependency-name: androidx.activity:activity-compose
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-22 16:39:17 -06:00
dependabot[bot]
629672cfd0 Bump androidx.core:core-ktx from 1.12.0 to 1.13.0
Bumps androidx.core:core-ktx from 1.12.0 to 1.13.0.

---
updated-dependencies:
- dependency-name: androidx.core:core-ktx
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-22 16:35:16 -06:00
dependabot[bot]
7c6efe5663 Bump androidx.compose.compiler:compiler from 1.5.11 to 1.5.12
Bumps androidx.compose.compiler:compiler from 1.5.11 to 1.5.12.

---
updated-dependencies:
- dependency-name: androidx.compose.compiler:compiler
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-22 16:31:32 -06:00
dependabot[bot]
ef6490d931 Bump androidx.compose.material:material-icons-extended
Bumps androidx.compose.material:material-icons-extended from 1.6.5 to 1.6.6.

---
updated-dependencies:
- dependency-name: androidx.compose.material:material-icons-extended
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-22 16:27:32 -06:00
dependabot[bot]
9d1f8f58b3 Bump compose from 1.6.5 to 1.6.6
Bumps `compose` from 1.6.5 to 1.6.6.

Updates `androidx.compose.ui:ui-test-junit4` from 1.6.5 to 1.6.6

Updates `androidx.compose.ui:ui-test-manifest` from 1.6.5 to 1.6.6

Updates `androidx.compose.ui:ui-tooling` from 1.6.5 to 1.6.6

Updates `androidx.compose.ui:ui` from 1.6.5 to 1.6.6

---
updated-dependencies:
- dependency-name: androidx.compose.ui:ui-test-junit4
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: androidx.compose.ui:ui-test-manifest
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: androidx.compose.ui:ui-tooling
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: androidx.compose.ui:ui
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-22 16:23:36 -06:00
dependabot[bot]
dbb46cbf85 Bump com.android.tools.build:gradle from 8.3.1 to 8.3.2
Bumps com.android.tools.build:gradle from 8.3.1 to 8.3.2.

---
updated-dependencies:
- dependency-name: com.android.tools.build:gradle
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-15 16:51:23 -06:00
dependabot[bot]
e0f59e3972 Bump androidx.compose.material:material-icons-extended
Bumps androidx.compose.material:material-icons-extended from 1.6.3 to 1.6.5.

---
updated-dependencies:
- dependency-name: androidx.compose.material:material-icons-extended
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-08 16:30:11 -06:00
dependabot[bot]
e00a96d6f3 Bump com.android.tools.build:gradle from 8.3.0 to 8.3.1
Bumps com.android.tools.build:gradle from 8.3.0 to 8.3.1.

---
updated-dependencies:
- dependency-name: com.android.tools.build:gradle
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-08 16:26:17 -06:00
dependabot[bot]
175297adda Bump compose from 1.6.4 to 1.6.5
Bumps `compose` from 1.6.4 to 1.6.5.

Updates `androidx.compose.ui:ui-test-junit4` from 1.6.4 to 1.6.5

Updates `androidx.compose.ui:ui-test-manifest` from 1.6.4 to 1.6.5

Updates `androidx.compose.ui:ui-tooling` from 1.6.4 to 1.6.5

Updates `androidx.compose.ui:ui` from 1.6.4 to 1.6.5

---
updated-dependencies:
- dependency-name: androidx.compose.ui:ui-test-junit4
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: androidx.compose.ui:ui-test-manifest
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: androidx.compose.ui:ui-tooling
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: androidx.compose.ui:ui
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-08 16:22:19 -06:00
dependabot[bot]
e5ddd110b5 Bump ktor from 2.3.9 to 2.3.10
Bumps `ktor` from 2.3.9 to 2.3.10.

Updates `io.ktor:ktor-client-cio` from 2.3.9 to 2.3.10
- [Release notes](https://github.com/ktorio/ktor/releases)
- [Changelog](https://github.com/ktorio/ktor/blob/2.3.10/CHANGELOG.md)
- [Commits](https://github.com/ktorio/ktor/compare/2.3.9...2.3.10)

Updates `io.ktor:ktor-client-ios` from 2.3.9 to 2.3.10
- [Release notes](https://github.com/ktorio/ktor/releases)
- [Changelog](https://github.com/ktorio/ktor/blob/2.3.10/CHANGELOG.md)
- [Commits](https://github.com/ktorio/ktor/compare/2.3.9...2.3.10)

Updates `io.ktor:ktor-client-js` from 2.3.9 to 2.3.10
- [Release notes](https://github.com/ktorio/ktor/releases)
- [Changelog](https://github.com/ktorio/ktor/blob/2.3.10/CHANGELOG.md)
- [Commits](https://github.com/ktorio/ktor/compare/2.3.9...2.3.10)

Updates `io.ktor:ktor-client-android` from 2.3.9 to 2.3.10
- [Release notes](https://github.com/ktorio/ktor/releases)
- [Changelog](https://github.com/ktorio/ktor/blob/2.3.10/CHANGELOG.md)
- [Commits](https://github.com/ktorio/ktor/compare/2.3.9...2.3.10)

Updates `io.ktor:ktor-client-content-negotiation` from 2.3.9 to 2.3.10
- [Release notes](https://github.com/ktorio/ktor/releases)
- [Changelog](https://github.com/ktorio/ktor/blob/2.3.10/CHANGELOG.md)
- [Commits](https://github.com/ktorio/ktor/compare/2.3.9...2.3.10)

Updates `io.ktor:ktor-serialization-kotlinx-json` from 2.3.9 to 2.3.10
- [Release notes](https://github.com/ktorio/ktor/releases)
- [Changelog](https://github.com/ktorio/ktor/blob/2.3.10/CHANGELOG.md)
- [Commits](https://github.com/ktorio/ktor/compare/2.3.9...2.3.10)

Updates `io.ktor:ktor-client-core` from 2.3.9 to 2.3.10
- [Release notes](https://github.com/ktorio/ktor/releases)
- [Changelog](https://github.com/ktorio/ktor/blob/2.3.10/CHANGELOG.md)
- [Commits](https://github.com/ktorio/ktor/compare/2.3.9...2.3.10)

Updates `io.ktor:ktor-client-logging` from 2.3.9 to 2.3.10
- [Release notes](https://github.com/ktorio/ktor/releases)
- [Changelog](https://github.com/ktorio/ktor/blob/2.3.10/CHANGELOG.md)
- [Commits](https://github.com/ktorio/ktor/compare/2.3.9...2.3.10)

---
updated-dependencies:
- dependency-name: io.ktor:ktor-client-cio
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: io.ktor:ktor-client-ios
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: io.ktor:ktor-client-js
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: io.ktor:ktor-client-android
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: io.ktor:ktor-client-content-negotiation
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: io.ktor:ktor-serialization-kotlinx-json
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: io.ktor:ktor-client-core
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: io.ktor:ktor-client-logging
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-08 16:18:14 -06:00
5e6ee151a2 Fix compose navigation version
This was clearly being pulled in transitively, though I'd like to have it explicitly defined in the event I move away from Hilt (or whatever was providing it)
2024-04-03 21:36:56 -06:00
dependabot[bot]
92733f28ed Bump kotlin from 1.9.22 to 1.9.23
---
updated-dependencies:
- dependency-name: org.jetbrains.kotlin:kotlin-gradle-plugin
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: org.jetbrains.kotlin:kotlin-reflect
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: org.jetbrains.kotlin:kotlin-stdlib
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: org.jetbrains.kotlin:kotlin-serialization
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-02 08:36:27 -06:00
dependabot[bot]
cdf8f6975d Bump androidx.compose.compiler:compiler from 1.5.10 to 1.5.11
Bumps androidx.compose.compiler:compiler from 1.5.10 to 1.5.11.

---
updated-dependencies:
- dependency-name: androidx.compose.compiler:compiler
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-02 08:30:17 -06:00
dependabot[bot]
e08e1584c7 Bump compose from 1.6.3 to 1.6.4
Bumps `compose` from 1.6.3 to 1.6.4.

Updates `androidx.compose.ui:ui-test-junit4` from 1.6.3 to 1.6.4

Updates `androidx.compose.ui:ui-test-manifest` from 1.6.3 to 1.6.4

Updates `androidx.compose.ui:ui-tooling` from 1.6.3 to 1.6.4

Updates `androidx.compose.ui:ui` from 1.6.3 to 1.6.4

---
updated-dependencies:
- dependency-name: androidx.compose.ui:ui-test-junit4
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: androidx.compose.ui:ui-test-manifest
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: androidx.compose.ui:ui-tooling
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: androidx.compose.ui:ui
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-01 16:47:57 -06:00
dependabot[bot]
49bcb7b7ca Bump hilt-android from 2.51 to 2.51.1
Bumps `hilt-android` from 2.51 to 2.51.1.

Updates `com.google.dagger:hilt-android-gradle-plugin` from 2.51 to 2.51.1
- [Release notes](https://github.com/google/dagger/releases)
- [Changelog](https://github.com/google/dagger/blob/master/CHANGELOG.md)
- [Commits](https://github.com/google/dagger/compare/dagger-2.51...dagger-2.51.1)

Updates `com.google.dagger:hilt-android` from 2.51 to 2.51.1
- [Release notes](https://github.com/google/dagger/releases)
- [Changelog](https://github.com/google/dagger/blob/master/CHANGELOG.md)
- [Commits](https://github.com/google/dagger/compare/dagger-2.51...dagger-2.51.1)

Updates `com.google.dagger:hilt-compiler` from 2.51 to 2.51.1
- [Release notes](https://github.com/google/dagger/releases)
- [Changelog](https://github.com/google/dagger/blob/master/CHANGELOG.md)
- [Commits](https://github.com/google/dagger/compare/dagger-2.51...dagger-2.51.1)

Updates `com.google.dagger:hilt-android-testing` from 2.51 to 2.51.1
- [Release notes](https://github.com/google/dagger/releases)
- [Changelog](https://github.com/google/dagger/blob/master/CHANGELOG.md)
- [Commits](https://github.com/google/dagger/compare/dagger-2.51...dagger-2.51.1)

---
updated-dependencies:
- dependency-name: com.google.dagger:hilt-android-gradle-plugin
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: com.google.dagger:hilt-android
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: com.google.dagger:hilt-compiler
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: com.google.dagger:hilt-android-testing
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-01 16:43:51 -06:00
dependabot[bot]
a6d50b4e12 Bump hilt-android from 2.49 to 2.51
Bumps `hilt-android` from 2.49 to 2.51.

Updates `com.google.dagger:hilt-android-gradle-plugin` from 2.49 to 2.51
- [Release notes](https://github.com/google/dagger/releases)
- [Changelog](https://github.com/google/dagger/blob/master/CHANGELOG.md)
- [Commits](https://github.com/google/dagger/compare/dagger-2.49...dagger-2.51)

Updates `com.google.dagger:hilt-android` from 2.49 to 2.51
- [Release notes](https://github.com/google/dagger/releases)
- [Changelog](https://github.com/google/dagger/blob/master/CHANGELOG.md)
- [Commits](https://github.com/google/dagger/compare/dagger-2.49...dagger-2.51)

Updates `com.google.dagger:hilt-compiler` from 2.49 to 2.51
- [Release notes](https://github.com/google/dagger/releases)
- [Changelog](https://github.com/google/dagger/blob/master/CHANGELOG.md)
- [Commits](https://github.com/google/dagger/compare/dagger-2.49...dagger-2.51)

Updates `com.google.dagger:hilt-android-testing` from 2.49 to 2.51
- [Release notes](https://github.com/google/dagger/releases)
- [Changelog](https://github.com/google/dagger/blob/master/CHANGELOG.md)
- [Commits](https://github.com/google/dagger/compare/dagger-2.49...dagger-2.51)

---
updated-dependencies:
- dependency-name: com.google.dagger:hilt-android-gradle-plugin
  dependency-type: direct:production
  update-type: version-update:semver-minor
- dependency-name: com.google.dagger:hilt-android
  dependency-type: direct:production
  update-type: version-update:semver-minor
- dependency-name: com.google.dagger:hilt-compiler
  dependency-type: direct:production
  update-type: version-update:semver-minor
- dependency-name: com.google.dagger:hilt-android-testing
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-28 09:14:10 -06:00
dependabot[bot]
ef66cd0ba6 bump com.russhwolf:multiplatform-settings-no-arg from 0.8.1 to 1.1.1
---
updated-dependencies:
- dependency-name: com.russhwolf:multiplatform-settings-no-arg
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-28 09:09:39 -06:00
dependabot[bot]
85e9e8e893 Bump ktor from 2.3.4 to 2.3.9
Bumps `ktor` from 2.3.4 to 2.3.9.

Updates `io.ktor:ktor-client-cio` from 2.3.4 to 2.3.9
- [Release notes](https://github.com/ktorio/ktor/releases)
- [Changelog](https://github.com/ktorio/ktor/blob/2.3.9/CHANGELOG.md)
- [Commits](https://github.com/ktorio/ktor/compare/2.3.4...2.3.9)

Updates `io.ktor:ktor-client-ios` from 2.3.4 to 2.3.9
- [Release notes](https://github.com/ktorio/ktor/releases)
- [Changelog](https://github.com/ktorio/ktor/blob/2.3.9/CHANGELOG.md)
- [Commits](https://github.com/ktorio/ktor/compare/2.3.4...2.3.9)

Updates `io.ktor:ktor-client-js` from 2.3.4 to 2.3.9
- [Release notes](https://github.com/ktorio/ktor/releases)
- [Changelog](https://github.com/ktorio/ktor/blob/2.3.9/CHANGELOG.md)
- [Commits](https://github.com/ktorio/ktor/compare/2.3.4...2.3.9)

Updates `io.ktor:ktor-client-android` from 2.3.4 to 2.3.9
- [Release notes](https://github.com/ktorio/ktor/releases)
- [Changelog](https://github.com/ktorio/ktor/blob/2.3.9/CHANGELOG.md)
- [Commits](https://github.com/ktorio/ktor/compare/2.3.4...2.3.9)

Updates `io.ktor:ktor-client-content-negotiation` from 2.3.4 to 2.3.9
- [Release notes](https://github.com/ktorio/ktor/releases)
- [Changelog](https://github.com/ktorio/ktor/blob/2.3.9/CHANGELOG.md)
- [Commits](https://github.com/ktorio/ktor/compare/2.3.4...2.3.9)

Updates `io.ktor:ktor-serialization-kotlinx-json` from 2.3.4 to 2.3.9
- [Release notes](https://github.com/ktorio/ktor/releases)
- [Changelog](https://github.com/ktorio/ktor/blob/2.3.9/CHANGELOG.md)
- [Commits](https://github.com/ktorio/ktor/compare/2.3.4...2.3.9)

Updates `io.ktor:ktor-client-core` from 2.3.4 to 2.3.9
- [Release notes](https://github.com/ktorio/ktor/releases)
- [Changelog](https://github.com/ktorio/ktor/blob/2.3.9/CHANGELOG.md)
- [Commits](https://github.com/ktorio/ktor/compare/2.3.4...2.3.9)

Updates `io.ktor:ktor-client-logging` from 2.3.4 to 2.3.9
- [Release notes](https://github.com/ktorio/ktor/releases)
- [Changelog](https://github.com/ktorio/ktor/blob/2.3.9/CHANGELOG.md)
- [Commits](https://github.com/ktorio/ktor/compare/2.3.4...2.3.9)

---
updated-dependencies:
- dependency-name: io.ktor:ktor-client-cio
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: io.ktor:ktor-client-ios
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: io.ktor:ktor-client-js
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: io.ktor:ktor-client-android
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: io.ktor:ktor-client-content-negotiation
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: io.ktor:ktor-serialization-kotlinx-json
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: io.ktor:ktor-client-core
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: io.ktor:ktor-client-logging
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-28 09:05:24 -06:00
fdd594f66c Fix permissions and run conditions on workflows 2024-03-28 09:00:34 -06:00
c3e566ba1c Fix test failures 2024-03-28 08:35:55 -06:00
be0e06d77e Fix test workflows 2024-03-28 08:35:55 -06:00
5c6ae7f153 Make gradle wrapper executable 2024-03-27 20:32:22 -06:00
ba1b306bd3 Fix test workflow 2024-03-27 20:32:22 -06:00
b0e605417f Update checkout action in jobs 2024-03-27 20:32:22 -06:00
3f4d7b8d76 Add GitHub workflows and Dependabot config 2024-03-17 10:12:49 -06:00
3980d2729b Drop compose material
Material3 is already present so legacy material is redundant
2024-03-17 10:12:49 -06:00
68c0724a52 Ignore release build files 2024-03-17 10:12:49 -06:00
096eb340f4 Convert build.gradle to build.gradle.kts 2024-03-17 10:12:49 -06:00
b5f8573f75 Rename package to com.wbrawner.twigs.android and bump dependency versions 2024-03-17 10:12:49 -06:00
455eed0872 Updates to transaction lists 2024-03-17 10:12:49 -06:00
31cf90dec0 Add month selection to budget overview page
Signed-off-by: William Brawner <me@wbrawner.com>
2024-03-17 10:12:49 -06:00
83e2135478 Bump dependencies
Signed-off-by: William Brawner <me@wbrawner.com>
2024-03-17 10:12:49 -06:00
3c1449ac3b Hide archived categories in transaction forms
Signed-off-by: William Brawner <me@wbrawner.com>
2024-03-17 10:12:49 -06:00
d904a806ed Fix package for Android code in shared module
Signed-off-by: William Brawner <me@wbrawner.com>
2024-03-17 10:12:49 -06:00
a3b318b4df Fix replace function
Signed-off-by: William Brawner <me@wbrawner.com>
2024-03-17 10:12:49 -06:00
ed4fd514f1 Too much to split into individual commits:
- updated theme
- changed title to budget name on all main tab pages
- various fixes to state handling

Signed-off-by: William Brawner <me@wbrawner.com>
2024-03-17 10:12:49 -06:00
c23c16ab40 Add more details to recurring transaction details screen
Signed-off-by: William Brawner <me@wbrawner.com>
2024-03-17 10:12:49 -06:00
4a745f57e7 Fix date formatting on Transaction details
Signed-off-by: William Brawner <me@wbrawner.com>
2024-03-17 10:12:49 -06:00
6e32b5f6a3 Implement editing of recurring transactions
Signed-off-by: William Brawner <me@wbrawner.com>
2024-03-17 10:12:49 -06:00
badafaffc7 Add ability to create/edit categories
Signed-off-by: William Brawner <me@wbrawner.com>
2024-03-17 10:12:49 -06:00
7b1b088080 WIP: Add support for Recurring Transactions
Signed-off-by: William Brawner <me@wbrawner.com>
2024-03-17 10:12:49 -06:00
e86899b9ee Bump AGP and Kotlin versions
Signed-off-by: William Brawner <me@wbrawner.com>
2024-03-17 10:12:49 -06:00
70401bccc4 Add CategoryDetailsScreen
Signed-off-by: William Brawner <me@wbrawner.com>
2024-03-17 10:12:49 -06:00
afe12c3e5d Fix transaction time picker not showing again after closing
Signed-off-by: William Brawner <me@wbrawner.com>
2024-03-17 10:12:49 -06:00
477e311a5e Work on Transaction form
Signed-off-by: William Brawner <me@wbrawner.com>
2024-03-17 10:12:49 -06:00
95e7361907 WIP: Migrate to Kotlin Multiplatform
Signed-off-by: William Brawner <me@wbrawner.com>
2024-03-17 10:12:49 -06:00
120d3dd70b WIP: Migrate to Kotlin Multiplatform 2024-03-17 10:12:49 -06:00
316949a6b5 Fix compilation/runtime errors
The app is still in a horribly barely-usable state but it's at least usable now
2022-06-15 20:53:16 -06:00
16b4823450 WIP: Migrate transaction form to compose 2021-08-26 07:08:50 -06:00
16b1d56be2 Convert LoginFragment to compose 2021-08-24 16:05:25 -06:00
8effad8683 Fix shortcut icon to be adaptive 2021-08-23 18:59:57 -06:00
15d935abc8 Update to new Splash Screen API 2021-08-23 15:57:25 -06:00
1e3788c670 Use Hilt for DI 2021-08-23 15:31:02 -06:00
ed05d9bd97 Replace Moshi with Kotlinx Serialization, Retrofit with Ktor, LiveData with Flow, and update dependencies 2021-08-23 14:36:17 -06:00
ed6babe09f Fixes for new Ktor API 2021-08-16 15:38:38 -06:00
b65edb31c6 Fix tint of edit icon 2021-07-12 17:36:59 -06:00
ecff9a7201 Fix category editing screen 2021-07-12 17:29:18 -06:00
4ec68a3a4d Use same targetSdk for all modules 2021-07-12 17:28:23 -06:00
408dda40d9 Improve transaction/category editing/creation 2021-05-31 18:04:32 -06:00
f4bf721b43 Fix balance responses for budgets and categories 2021-02-15 22:25:16 -07:00
e2b6593a80 Show description in transaction list 2021-02-15 13:50:37 -07:00
d8800838c5 Bump Android Gradle Plugin version 2021-02-15 09:39:54 -07:00
0e7da4c40a Fix transaction querying
The requested date ranges were a little off
2021-02-15 09:39:34 -07:00
e533d98a07 Load budget balance from repository 2021-02-15 09:38:50 -07:00
b7452089de Allow cleartext traffic
Since the idea is for anyone to host their own server, forcing them to use SSL seems a bit heavy-handed, even if it's maybe not a bad idea.
2021-02-15 09:37:40 -07:00
4964fe17a8 Use Ubuntu font throughout the app 2021-01-27 16:18:18 -07:00
ed734651f9 Add NavigationDrawer to MainActivity to switch between budgets or create new ones 2021-01-27 16:18:18 -07:00
ace85f324f Fix crashes related to dialog styles 2021-01-26 21:46:17 -07:00
a58b97c0e9 Remove ACRA 2021-01-26 21:46:17 -07:00
c0ca8c8b29 Use string identifiers instead of ints 2021-01-26 21:46:17 -07:00
a56406caa9 Allow user to set URL at login 2021-01-03 07:57:49 -07:00
5db8a419de Add helper method for updating state 2021-01-03 07:56:43 -07:00
98b44e4f59 Rename activity_transaction_list to activity_main 2020-12-29 12:09:18 -07:00
97aab0624e Fix compilation errors 2020-12-29 11:53:42 -07:00
195 changed files with 7082 additions and 4122 deletions

6
.github/dependabot.yml vendored Normal file
View file

@ -0,0 +1,6 @@
version: 2
updates:
- package-ecosystem: "gradle"
directory: "/"
schedule:
interval: "weekly"

21
.github/workflows/auto-merge.yml vendored Normal file
View file

@ -0,0 +1,21 @@
name: Enable Auto Merge
on:
pull_request_target:
types:
- opened
- reopened
- edited
branches:
- main
jobs:
auto-merge:
runs-on: ubuntu-latest
if: ${{ github.actor == 'wbrawner' || github.actor == 'dependabot[bot]' }}
steps:
- name: Enable auto-merge
run: gh pr merge --auto --rebase "$PR_URL"
env:
PR_URL: ${{github.event.pull_request.html_url}}
GH_TOKEN: ${{secrets.GH_TOKEN}}

74
.github/workflows/test.yml vendored Normal file
View file

@ -0,0 +1,74 @@
name: Test
on:
pull_request:
permissions:
statuses: write
checks: write
jobs:
validate:
runs-on: ubuntu-latest
name: Validate
steps:
- uses: actions/checkout@v3
- name: set up JDK
uses: actions/setup-java@v3
with:
distribution: 'zulu'
java-version: '17'
- name: Validate Gradle Wrapper
uses: gradle/wrapper-validation-action@v1
unit_test:
name: Run Unit Tests
runs-on: ubuntu-latest
needs:
- validate
steps:
- uses: actions/checkout@v3
- name: set up JDK
uses: actions/setup-java@v3
with:
distribution: 'zulu'
java-version: '17'
- name: Run unit tests
uses: gradle/gradle-build-action@v2
with:
arguments: testDebugUnitTest
- name: Publish JUnit Results
uses: dorny/test-reporter@v1
if: always()
with:
name: Unit Test Results
path: "*/build/test-results/*/*.xml"
reporter: java-junit
fail-on-error: true
# TODO: Uncomment the UI test workflow when I actually have UI tests
# ui_tests:
# runs-on: ubuntu-latest
# name: Run UI Tests
# needs:
# - validate
# steps:
# - uses: actions/checkout@v3
# - name: set up JDK
# uses: actions/setup-java@v3
# with:
# distribution: 'zulu'
# java-version: '17'
# - name: Build with Gradle
# uses: gradle/gradle-build-action@v2
# with:
# arguments: assembleDebug assembleDebugAndroidTest
# - name: Grant execute permission for flank_auth.sh
# run: chmod +x flank_auth.sh
# - name: Add auth for flank
# env:
# GCLOUD_KEY: ${{ secrets.GCLOUD_KEY }}
# run: |
# ./flank_auth.sh
# - name: Run UI tests
# uses: gradle/gradle-build-action@v2
# with:
# arguments: runFlank

1
android/.gitignore vendored
View file

@ -1,2 +1,3 @@
/build
acra.properties
release/

View file

@ -1,93 +0,0 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
apply plugin: 'kotlin-kapt'
def acraProperties = new Properties()
try {
def acraPropertiesFile = project.file("acra.properties")
acraProperties.load(new FileInputStream(acraPropertiesFile))
} catch (FileNotFoundException ignored) {
logger.warn("Unable to load ACRA properties. Error reporting won't be available")
acraProperties['url'] = ""
acraProperties['user'] = ""
acraProperties['pass'] = ""
}
android {
compileSdkVersion 30
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
defaultConfig {
applicationId "com.wbrawner.budget"
minSdkVersion 23
targetSdkVersion 30
versionCode 1
versionName "1.0"
vectorDrawables {
useSupportLibrary = true
}
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
// buildConfigField "String", "API_URL", "\"http://192.168.86.163:8080/\""
// buildConfigField "String", "API_URL", "\"http://10.0.2.2:8080/\""
buildConfigField "String", "API_URL", "\"https://api.twigs.brawner.dev/\""
buildConfigField "String", "ACRA_URL", "\"${acraProperties['url']}\""
buildConfigField "String", "ACRA_USER", "\"${acraProperties['user']}\""
buildConfigField "String", "ACRA_PASS", "\"${acraProperties['pass']}\""
}
buildTypes {
release {
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
buildConfigField "String", "API_URL", "\"https://budget-api.intra.wbrawner.com/\""
}
}
sourceSets {
androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
}
}
dependencies {
implementation project(':common')
implementation project(':budgetlib')
implementation project(':storage')
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation 'androidx.legacy:legacy-support-v4:1.0.0'
implementation 'androidx.appcompat:appcompat:1.2.0'
implementation 'androidx.core:core:1.3.2'
implementation 'androidx.media:media:1.2.1'
implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
implementation 'com.google.android.material:material:1.2.1'
implementation 'androidx.emoji:emoji-bundled:1.1.0'
implementation 'com.github.BlacKCaT27:CurrencyEditText:2.0.2'
implementation "androidx.lifecycle:lifecycle-extensions:$lifecycle_version"
// Dagger
implementation "com.google.dagger:dagger:$dagger"
kapt "com.google.dagger:dagger-compiler:$dagger"
kapt "com.squareup.moshi:moshi-kotlin-codegen:$moshi"
testImplementation 'junit:junit:4.12'
androidTestImplementation 'androidx.test:runner:1.3.0'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.0'
implementation 'androidx.navigation:navigation-fragment-ktx:2.3.2'
implementation 'androidx.navigation:navigation-ui-ktx:2.3.2'
implementation "ch.acra:acra-http:$acra_version"
implementation "ch.acra:acra-advanced-scheduler:$acra_version"
debugImplementation "com.willowtreeapps.hyperion:hyperion-core:$hyperion"
debugImplementation "com.willowtreeapps.hyperion:hyperion-attr:$hyperion"
debugImplementation "com.willowtreeapps.hyperion:hyperion-build-config:$hyperion"
debugImplementation "com.willowtreeapps.hyperion:hyperion-crash:$hyperion"
debugImplementation "com.willowtreeapps.hyperion:hyperion-disk:$hyperion"
debugImplementation "com.willowtreeapps.hyperion:hyperion-geiger-counter:$hyperion"
debugImplementation "com.willowtreeapps.hyperion:hyperion-measurement:$hyperion"
debugImplementation "com.willowtreeapps.hyperion:hyperion-phoenix:$hyperion"
debugImplementation "com.willowtreeapps.hyperion:hyperion-recorder:$hyperion"
debugImplementation "com.willowtreeapps.hyperion:hyperion-shared-preferences:$hyperion"
}

92
android/build.gradle.kts Normal file
View file

@ -0,0 +1,92 @@
import java.io.FileInputStream
import java.io.FileNotFoundException
import java.util.Properties
plugins {
id("com.android.application")
id("kotlin-android")
id("kotlin-kapt")
id("dagger.hilt.android.plugin")
}
val keystoreProperties = Properties()
try {
val keystorePropertiesFile = rootProject.file("keystore.properties")
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
} catch (ignored: FileNotFoundException) {
logger.warn("Unable to load keystore properties. Using debug signing configuration instead")
keystoreProperties["keyAlias"] = "androiddebugkey"
keystoreProperties["keyPassword"] = "android"
keystoreProperties["storeFile"] =
File(System.getProperty("user.home"), ".android/debug.keystore").absolutePath
keystoreProperties["storePassword"] = "android"
}
android {
namespace = "com.wbrawner.twigs.android"
compileSdk = libs.versions.maxSdk.get().toInt()
defaultConfig {
applicationId = "com.wbrawner.twigs"
minSdk = libs.versions.minSdk.get().toInt()
targetSdk = libs.versions.maxSdk.get().toInt()
versionCode = libs.versions.versionCode.get().toInt()
versionName = libs.versions.versionName.get()
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
signingConfig = signingConfigs["debug"]
}
signingConfigs {
create("release") {
keyAlias = keystoreProperties["keyAlias"].toString()
keyPassword = keystoreProperties["keyPassword"].toString()
storeFile = file(keystoreProperties["storeFile"].toString())
storePassword = keystoreProperties["storePassword"].toString()
}
}
buildTypes {
release {
isMinifyEnabled = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
signingConfig = signingConfigs["release"]
}
}
compileOptions {
sourceCompatibility = project.ext["jvm"] as JavaVersion
targetCompatibility = project.ext["jvm"] as JavaVersion
}
kotlinOptions {
jvmTarget = (project.ext["jvm"] as JavaVersion).majorVersion
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get()
}
}
dependencies {
implementation(project(":shared"))
implementation(libs.bundles.coroutines)
implementation(libs.bundles.compose)
implementation(libs.hilt.android.core)
implementation(libs.hilt.navigation.compose)
kapt(libs.hilt.android.kapt)
implementation(libs.androidx.core)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.splash)
implementation(libs.material)
implementation("androidx.legacy:legacy-support-v4:1.0.0")
implementation(libs.preference)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.test.runner)
androidTestUtil(libs.androidx.test.orchestrator)
androidTestImplementation(libs.test.ext)
androidTestImplementation(libs.espresso)
androidTestImplementation(libs.hilt.android.testing)
kaptAndroidTest(libs.hilt.android.kapt)
androidTestImplementation(libs.compose.test.junit)
debugImplementation(libs.compose.test.manifest)
}

View file

@ -1,6 +1,6 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
# proguardFiles setting in build.gradle.kts.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html

View file

@ -1,4 +1,4 @@
package com.wbrawner.budget
package com.wbrawner.twigs
import androidx.test.InstrumentationRegistry
import androidx.test.runner.AndroidJUnit4

View file

@ -1,4 +1,8 @@
package com.wbrawner.budget;
package com.wbrawner.twigs;
import static junit.framework.Assert.assertNull;
import static junit.framework.TestCase.assertEquals;
import static junit.framework.TestCase.assertTrue;
import android.arch.persistence.db.SupportSQLiteDatabase;
import android.arch.persistence.db.framework.FrameworkSQLiteOpenHelperFactory;
@ -8,9 +12,9 @@ import android.database.Cursor;
import androidx.test.InstrumentationRegistry;
import androidx.test.runner.AndroidJUnit4;
import com.wbrawner.budget.data.BudgetDatabase;
import com.wbrawner.budget.data.migrations.MIGRATION_1_2;
import com.wbrawner.budget.data.migrations.MIGRATION_2_3;
import com.wbrawner.twigs.data.BudgetDatabase;
import com.wbrawner.twigs.data.migrations.MIGRATION_1_2;
import com.wbrawner.twigs.data.migrations.MIGRATION_2_3;
import org.junit.Rule;
import org.junit.Test;
@ -18,10 +22,6 @@ import org.junit.runner.RunWith;
import java.io.IOException;
import static junit.framework.Assert.assertNull;
import static junit.framework.TestCase.assertEquals;
import static junit.framework.TestCase.assertTrue;
@RunWith(AndroidJUnit4.class)
public class MigrationTests {
private static final String TEST_DB = "migration-test";

View file

@ -1,22 +1,22 @@
<?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.budget">
xmlns:tools="http://schemas.android.com/tools">
<application
android:name=".AllowanceApplication"
android:name=".TwigsApplication"
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/AppTheme"
android:networkSecurityConfig="@xml/network_security_config"
tools:ignore="GoogleAppIndexingWarning"
tools:targetApi="n">
android:usesCleartextTraffic="true"
tools:ignore="GoogleAppIndexingWarning">
<activity
android:name=".ui.SplashActivity"
android:theme="@style/SplashTheme"
android:name=".ui.MainActivity"
android:exported="true"
android:resizeableActivity="true"
android:theme="@style/Theme.App.Starting"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@ -27,25 +27,6 @@
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
</activity>
<activity android:name=".ui.MainActivity"
android:theme="@style/AppTheme" />
<activity
android:name=".ui.transactions.TransactionFormActivity"
android:parentActivityName=".ui.MainActivity"
android:theme="@style/AppTheme">
<meta-data
android:name="android.support.PARENT_ACTIVITY"
android:value=".ui.MainActivity" />
</activity>
<activity
android:name=".ui.categories.CategoryFormActivity"
android:parentActivityName=".ui.MainActivity"
android:theme="@style/AppTheme">
<meta-data
android:name="android.support.PARENT_ACTIVITY"
android:value=".ui.MainActivity" />
</activity>
</application>
</manifest>

View file

@ -1,17 +0,0 @@
package com.wbrawner.budget
import android.app.Application
class AllowanceApplication : Application() {
lateinit var appComponent: AppComponent
private set
override fun onCreate() {
super.onCreate()
appComponent = DaggerAppComponent.builder()
.baseUrl(BuildConfig.API_URL)
.context(this)
.build()
appComponent.errorHandler.init(this)
}
}

View file

@ -1,46 +0,0 @@
package com.wbrawner.budget
import android.content.Context
import com.wbrawner.budget.common.util.ErrorHandler
import com.wbrawner.budget.lib.network.NetworkModule
import com.wbrawner.budget.storage.StorageModule
import com.wbrawner.budget.ui.SplashViewModel
import com.wbrawner.budget.ui.budgets.BudgetFormViewModel
import com.wbrawner.budget.ui.budgets.BudgetListViewModel
import com.wbrawner.budget.ui.categories.CategoryDetailsViewModel
import com.wbrawner.budget.ui.categories.CategoryFormViewModel
import com.wbrawner.budget.ui.categories.CategoryListViewModel
import com.wbrawner.budget.ui.overview.OverviewViewModel
import com.wbrawner.budget.ui.transactions.TransactionFormViewModel
import com.wbrawner.budget.ui.transactions.TransactionListViewModel
import dagger.BindsInstance
import dagger.Component
import javax.inject.Named
import javax.inject.Singleton
@Singleton
@Component(modules = [AppModule::class, StorageModule::class, NetworkModule::class])
interface AppComponent {
fun inject(viewModel: OverviewViewModel)
fun inject(viewModel: SplashViewModel)
fun inject(viewMode: BudgetListViewModel)
fun inject(viewModel: BudgetFormViewModel)
fun inject(viewModel: CategoryListViewModel)
fun inject(viewModel: CategoryDetailsViewModel)
fun inject(viewModel: CategoryFormViewModel)
fun inject(viewModel: TransactionListViewModel)
fun inject(viewModel: TransactionFormViewModel)
@Singleton
val errorHandler: ErrorHandler
@Component.Builder
interface Builder {
@BindsInstance
fun baseUrl(@Named("baseUrl") baseUrl: String): Builder
@BindsInstance
fun context(context: Context): Builder
fun build(): AppComponent
}
}

View file

@ -1,12 +0,0 @@
package com.wbrawner.budget
import com.wbrawner.budget.common.util.ErrorHandler
import com.wbrawner.budget.util.AcraErrorHandler
import dagger.Module
import dagger.Provides
@Module
class AppModule {
@Provides
fun provideErrorHandler(): ErrorHandler = AcraErrorHandler()
}

View file

@ -1,33 +0,0 @@
package com.wbrawner.budget
import android.util.Log
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
sealed class AsyncState<out T> {
object Loading : AsyncState<Nothing>()
class Success<T>(val data: T) : AsyncState<T>()
class Error(val exception: Exception) : AsyncState<Nothing>() {
constructor(message: String) : this(RuntimeException(message))
}
object Exit : AsyncState<Nothing>()
}
interface AsyncViewModel<T> {
val state: MutableLiveData<AsyncState<T>>
}
fun <VM, T> VM.launch(block: suspend () -> T): Job where VM : ViewModel, VM : AsyncViewModel<T> = viewModelScope.launch {
state.postValue(AsyncState.Loading)
try {
state.postValue(AsyncState.Success(block()))
} catch (e: Exception) {
state.postValue(AsyncState.Error(e))
Log.e("AsyncViewModel", "Failed to load data", e)
}
}

View file

@ -1,6 +0,0 @@
package com.wbrawner.budget.ui
const val EXTRA_BUDGET_ID = "budgetId"
const val EXTRA_CATEGORY_ID = "categoryId"
const val EXTRA_CATEGORY_NAME = "categoryName"
const val EXTRA_TRANSACTION_ID = "transactionId"

View file

@ -1,44 +0,0 @@
package com.wbrawner.budget.ui
import android.os.Bundle
import android.view.MenuItem
import androidx.appcompat.app.AppCompatActivity
import androidx.emoji.text.EmojiCompat
import androidx.navigation.findNavController
import androidx.navigation.ui.setupWithNavController
import com.wbrawner.budget.R
import kotlinx.android.synthetic.main.activity_transaction_list.*
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
EmojiCompat.init(androidx.emoji.bundled.BundledEmojiCompatConfig(this))
setContentView(R.layout.activity_transaction_list)
setSupportActionBar(action_bar)
val navController = findNavController(R.id.content_container)
menu_main.setupWithNavController(navController)
navController.addOnDestinationChangedListener { _, destination, _ ->
title = destination.label
val showHomeAsUp = when (destination.label) {
getString(R.string.title_overview) -> false
getString(R.string.title_transactions) -> false
getString(R.string.title_profile) -> false
getString(R.string.title_categories) -> false
else -> true
}
supportActionBar?.setDisplayHomeAsUpEnabled(showHomeAsUp)
}
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == android.R.id.home) {
findNavController(R.id.content_container).navigateUp()
return true
}
return super.onOptionsItemSelected(item)
}
companion object {
const val EXTRA_OPEN_FRAGMENT = "com.wbrawner.budget.MainActivity.EXTRA_OPEN_FRAGMENT"
}
}

View file

@ -1,52 +0,0 @@
package com.wbrawner.budget.ui
import android.os.Bundle
import android.view.View
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.Observer
import androidx.navigation.findNavController
import com.wbrawner.budget.AllowanceApplication
import com.wbrawner.budget.AsyncState
import com.wbrawner.budget.R
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlin.coroutines.CoroutineContext
class SplashActivity : AppCompatActivity(), CoroutineScope {
override val coroutineContext: CoroutineContext = Dispatchers.Main
private val viewModel: SplashViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
(application as AllowanceApplication).appComponent.inject(viewModel)
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_splash)
window.decorView.apply {
systemUiVisibility = (
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
)
}
val navController = findNavController(R.id.auth_content)
viewModel.state.observe(this, Observer { state ->
when (state) {
is AsyncState.Success -> {
when (state.data) {
is AuthenticationState.Authenticated -> {
navController.navigate(R.id.mainActivity)
finish()
}
is AuthenticationState.Unauthenticated -> {
navController.navigate(R.id.loginFragment)
}
}
}
}
})
launch {
viewModel.checkForExistingCredentials()
}
}
}

View file

@ -1,53 +0,0 @@
package com.wbrawner.budget.ui
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import com.wbrawner.budget.AsyncState
import com.wbrawner.budget.AsyncViewModel
import com.wbrawner.budget.common.budget.BudgetRepository
import com.wbrawner.budget.common.user.UserRepository
import com.wbrawner.budget.launch
import javax.inject.Inject
class SplashViewModel : ViewModel(), AsyncViewModel<AuthenticationState> {
override val state: MutableLiveData<AsyncState<AuthenticationState>> = MutableLiveData(AsyncState.Loading)
@Inject
lateinit var budgetRepository: BudgetRepository
@Inject
lateinit var userRepository: UserRepository
suspend fun checkForExistingCredentials() {
state.postValue(AsyncState.Success(AuthenticationState.Splash))
val authState = try {
userRepository.getProfile()
AuthenticationState.Authenticated
} catch (ignored: Exception) {
AuthenticationState.Unauthenticated
}
state.postValue(AsyncState.Success(authState))
}
fun login(username: String, password: String) = launch {
try {
userRepository.login(username, password).also {
loadBudgetData()
}
AuthenticationState.Authenticated
} catch (ignored: Exception) {
// TODO: Return error message here
AuthenticationState.Unauthenticated
}
}
private suspend fun loadBudgetData() {
budgetRepository.prefetchData()
}
}
sealed class AuthenticationState {
object Splash : AuthenticationState()
object Unauthenticated : AuthenticationState()
object Authenticated : AuthenticationState()
}

View file

@ -1,77 +0,0 @@
package com.wbrawner.budget.ui.auth
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.Observer
import com.wbrawner.budget.AsyncState
import com.wbrawner.budget.R
import com.wbrawner.budget.ui.SplashViewModel
import com.wbrawner.budget.ui.ensureNotEmpty
import com.wbrawner.budget.ui.show
import kotlinx.android.synthetic.main.fragment_login.*
/**
* A simple [Fragment] subclass.
*/
class LoginFragment : Fragment() {
private val viewModel: SplashViewModel by activityViewModels()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? = inflater.inflate(R.layout.fragment_login, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewModel.state.observe(viewLifecycleOwner, Observer { state ->
when (state) {
is AsyncState.Loading -> {
handleLoading(true)
}
is AsyncState.Error -> {
handleLoading(false)
username.error = "Invalid username/password"
password.error = "Invalid username/password"
state.exception.printStackTrace()
}
}
})
password.setOnEditorActionListener { _, _, _ ->
submit.performClick()
}
submit.setOnClickListener {
if (!username.ensureNotEmpty() || !password.ensureNotEmpty()) {
return@setOnClickListener
}
viewModel.login(username.text.toString(), password.text.toString())
}
val usernameString = arguments?.getString(EXTRA_USERNAME)
val passwordString = arguments?.getString(EXTRA_PASSWORD)
if (!usernameString.isNullOrBlank() && !passwordString.isNullOrBlank()) {
username.setText(usernameString)
password.setText(passwordString)
submit.performClick()
}
}
private fun handleLoading(isLoading: Boolean) {
formPrompt.show(!isLoading)
usernameContainer.show(!isLoading)
passwordContainer.show(!isLoading)
submit.show(!isLoading)
registerButton.show(!isLoading)
forgotPasswordLink.show(!isLoading)
progressBar.show(isLoading)
}
companion object {
const val EXTRA_USERNAME = "username"
const val EXTRA_PASSWORD = "password"
}
}

View file

@ -1,23 +0,0 @@
package com.wbrawner.budget.ui.auth
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import com.wbrawner.budget.R
/**
* A simple [Fragment] subclass.
*/
class RegisterFragment : Fragment() {
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?): View? {
// Inflate the layout for this fragment
return inflater.inflate(R.layout.fragment_login, container, false)
}
}

View file

@ -1,83 +0,0 @@
package com.wbrawner.budget.ui.base
import android.annotation.SuppressLint
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.annotation.StringRes
import androidx.fragment.app.Fragment
import androidx.lifecycle.Observer
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.LinearLayoutManager
import com.wbrawner.budget.AsyncState
import com.wbrawner.budget.AsyncViewModel
import com.wbrawner.budget.R
import com.wbrawner.budget.common.Identifiable
import com.wbrawner.budget.ui.hideFabOnScroll
import kotlinx.android.synthetic.main.fragment_list_with_add_button.*
abstract class ListWithAddButtonFragment<Data : Identifiable, ViewModel : AsyncViewModel<List<Data>>> : Fragment() {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? = inflater.inflate(R.layout.fragment_list_with_add_button, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
recyclerView.layoutManager = LinearLayoutManager(view.context)
recyclerView.hideFabOnScroll(addFab)
val adapter = BindableAdapter(constructors, diffUtilItemCallback)
recyclerView.adapter = adapter
addFab.setOnClickListener {
addItem()
}
noItemsTextView.setText(noItemsStringRes)
viewModel.state.observe(viewLifecycleOwner, Observer { state ->
when (state) {
is AsyncState.Loading -> {
progressBar.visibility = View.VISIBLE
listContainer.visibility = View.GONE
noItemsTextView.visibility = View.GONE
}
is AsyncState.Success -> {
progressBar.visibility = View.GONE
listContainer.visibility = View.VISIBLE
noItemsTextView.visibility = View.GONE
adapter.submitList(state.data.map { bindData(it) })
}
is AsyncState.Error -> {
// TODO: Show an error message
progressBar.visibility = View.GONE
listContainer.visibility = View.GONE
noItemsTextView.visibility = View.VISIBLE
}
}
})
}
override fun onStart() {
super.onStart()
reloadItems()
}
abstract val viewModel: ViewModel
abstract fun reloadItems()
@get:StringRes
abstract val noItemsStringRes: Int
abstract fun addItem()
abstract fun bindData(data: Data): BindableData<Data>
abstract val constructors: Map<Int, (View) -> BindableAdapter.BindableViewHolder<Data>>
open val diffUtilItemCallback: DiffUtil.ItemCallback<BindableData<Data>> = object: DiffUtil.ItemCallback<BindableData<Data>>() {
override fun areItemsTheSame(oldItem: BindableData<Data>, newItem: BindableData<Data>): Boolean {
return oldItem.data.id === newItem.data.id
}
@SuppressLint("DiffUtilEquals")
override fun areContentsTheSame(oldItem: BindableData<Data>, newItem: BindableData<Data>): Boolean {
return oldItem.data == newItem.data
}
}
}

View file

@ -1,55 +0,0 @@
package com.wbrawner.budget.ui.base
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.annotation.LayoutRes
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlin.coroutines.CoroutineContext
class BindableAdapter<Data>(
private val constructors: Map<Int, (view: View) -> BindableViewHolder<Data>>,
diffUtilItemCallback: DiffUtil.ItemCallback<BindableData<Data>>
) : ListAdapter<BindableData<Data>, BindableAdapter.BindableViewHolder<Data>>(diffUtilItemCallback) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int)
: BindableViewHolder<Data> = constructors[viewType]
?.invoke(LayoutInflater.from(parent.context).inflate(viewType, parent, false))
?: throw IllegalStateException("Attempted to create ViewHolder without proper constructor provided")
override fun onBindViewHolder(holder: BindableViewHolder<Data>, position: Int) {
holder.onBind(getItem(position))
}
override fun onViewRecycled(holder: BindableViewHolder<Data>) {
holder.onUnbind()
}
override fun getItemViewType(position: Int): Int = getItem(position).viewType
abstract class BindableViewHolder<T>(itemView: View) : RecyclerView.ViewHolder(itemView), Bindable<BindableData<T>>
abstract class CoroutineViewHolder<T>(itemView: View) : BindableViewHolder<T>(itemView), CoroutineScope {
override val coroutineContext: CoroutineContext = Dispatchers.Main
override fun onUnbind() {
coroutineContext[Job]?.cancel()
super.onUnbind()
}
}
}
interface Bindable<T> {
fun onBind(item: T) {}
fun onUnbind() {}
}
data class BindableData<T>(
val data: T,
@get:LayoutRes
val viewType: Int
)

View file

@ -1,102 +0,0 @@
package com.wbrawner.budget.ui.budgets
import android.content.Context
import android.os.Bundle
import android.view.*
import android.widget.ArrayAdapter
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.Observer
import androidx.navigation.fragment.findNavController
import com.wbrawner.budget.AllowanceApplication
import com.wbrawner.budget.R
import com.wbrawner.budget.common.budget.Budget
import com.wbrawner.budget.common.user.User
import com.wbrawner.budget.ui.EXTRA_BUDGET_ID
import kotlinx.android.synthetic.main.fragment_add_edit_budget.*
class AddEditBudgetFragment : Fragment() {
private val viewModel: BudgetFormViewModel by viewModels()
var id: Long? = null
var menu: Menu? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setHasOptionsMenu(true)
}
override fun onAttach(context: Context) {
(requireActivity().application as AllowanceApplication)
.appComponent
.inject(viewModel)
super.onAttach(context)
viewModel.getBudget(arguments?.getLong(EXTRA_BUDGET_ID))
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? = inflater.inflate(R.layout.fragment_add_edit_budget, container, false)
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.menu_add_edit, menu)
if (id != null) {
menu.findItem(R.id.action_delete)?.isVisible = true
}
this.menu = menu
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val suggestionsAdapter = ArrayAdapter<User>(
requireContext(),
android.R.layout.simple_spinner_item,
mutableListOf()
)
usersSearch.setAdapter(suggestionsAdapter)
viewModel.userSuggestions.observe(viewLifecycleOwner, Observer {
suggestionsAdapter.clear()
suggestionsAdapter.addAll(it)
})
viewModel.state.observe(viewLifecycleOwner, Observer { state ->
when (state) {
is BudgetFormState.Loading -> {
progressBar.visibility = View.VISIBLE
budgetForm.visibility = View.GONE
}
is BudgetFormState.Success -> {
budgetForm.visibility = View.VISIBLE
progressBar.visibility = View.GONE
activity?.setTitle(state.titleRes)
menu?.findItem(R.id.action_delete)?.isVisible = state.showDeleteButton
id = state.budget.id
name.setText(state.budget.name)
description.setText(state.budget.description)
}
is BudgetFormState.Exit -> {
findNavController().navigateUp()
}
}
})
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
android.R.id.home -> findNavController().navigateUp()
R.id.action_save -> {
viewModel.saveBudget(Budget(
id = id,
name = name.text.toString(),
description = description.text.toString(),
users = viewModel.users.value ?: emptyList()
))
}
R.id.action_delete -> {
viewModel.deleteBudget(this@AddEditBudgetFragment.id!!)
}
}
return true
}
}

View file

@ -1,100 +0,0 @@
package com.wbrawner.budget.ui.budgets
import androidx.annotation.StringRes
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.wbrawner.budget.R
import com.wbrawner.budget.common.budget.Budget
import com.wbrawner.budget.common.budget.BudgetRepository
import com.wbrawner.budget.common.user.User
import com.wbrawner.budget.common.user.UserRepository
import kotlinx.coroutines.launch
import java.lang.Exception
import javax.inject.Inject
class BudgetFormViewModel : ViewModel() {
val state = MutableLiveData<BudgetFormState>(BudgetFormState.Loading)
val users = MutableLiveData<List<User>>()
val userSuggestions = MutableLiveData<List<User>>()
@Inject
lateinit var budgetRepository: BudgetRepository
@Inject
lateinit var userRepository: UserRepository
fun getBudget(id: Long? = null) {
viewModelScope.launch {
state.postValue(BudgetFormState.Loading)
try {
val budget = id?.let {
budgetRepository.findById(it)
}?: Budget(name = "")
state.postValue(BudgetFormState.Success(budget))
} catch (e: Exception) {
state.postValue(BudgetFormState.Failed(e))
}
}
}
fun saveBudget(budget: Budget) {
viewModelScope.launch {
state.postValue(BudgetFormState.Loading)
try {
if (budget.id != null) {
budgetRepository.update(budget)
} else {
budgetRepository.create(budget)
}
state.postValue(BudgetFormState.Exit)
} catch (e: Exception) {
state.postValue(BudgetFormState.Failed(e))
}
}
}
fun deleteBudget(accountId: Long) {
viewModelScope.launch {
state.postValue(BudgetFormState.Loading)
try {
budgetRepository.delete(accountId)
state.postValue(BudgetFormState.Exit)
} catch (e: Exception) {
state.postValue(BudgetFormState.Failed(e))
}
}
}
fun searchUsers(query: String) {
if (query.isBlank()) {
userSuggestions.value = emptyList()
return
}
viewModelScope.launch {
userSuggestions.value = userRepository.findAllByNameLike(query).toList()
}
}
fun addUser(user: User) {
users.value
}
}
sealed class BudgetFormState {
object Loading: BudgetFormState()
class Success(
@StringRes val titleRes: Int,
val showDeleteButton: Boolean,
val budget: Budget
): BudgetFormState() {
constructor(budget: Budget): this(
budget.id?.let { R.string.title_edit_budget }?: R.string.title_add_budget,
budget.id != null,
budget
)
}
class Failed(val exception: Exception): BudgetFormState()
object Exit: BudgetFormState()
}

View file

@ -1,66 +0,0 @@
package com.wbrawner.budget.ui.budgets
import android.content.Context
import android.os.Bundle
import android.view.View
import android.widget.TextView
import androidx.fragment.app.viewModels
import androidx.navigation.NavController
import androidx.navigation.fragment.findNavController
import com.wbrawner.budget.AllowanceApplication
import com.wbrawner.budget.R
import com.wbrawner.budget.common.budget.Budget
import com.wbrawner.budget.ui.EXTRA_BUDGET_ID
import com.wbrawner.budget.ui.base.BindableAdapter
import com.wbrawner.budget.ui.base.BindableData
import com.wbrawner.budget.ui.base.ListWithAddButtonFragment
class BudgetListFragment : ListWithAddButtonFragment<Budget, BudgetListViewModel>() {
override val noItemsStringRes: Int = R.string.overview_no_data
override fun onAttach(context: Context) {
(requireActivity().application as AllowanceApplication).appComponent.inject(viewModel)
super.onAttach(context)
}
override val viewModel: BudgetListViewModel by viewModels()
override fun reloadItems() {
viewModel.getBudgets()
}
override fun bindData(data: Budget): BindableData<Budget> = BindableData(data, BUDGET_VIEW)
override val constructors: Map<Int, (View) -> BindableAdapter.BindableViewHolder<Budget>>
get() = mapOf(BUDGET_VIEW to { v -> BudgetViewHolder(v, findNavController()) })
override fun addItem() {
findNavController().navigate(R.id.addEditBudget)
}
}
const val BUDGET_VIEW = R.layout.list_item_budget
class BudgetViewHolder(itemView: View, val navController: NavController) : BindableAdapter.BindableViewHolder<Budget>(itemView) {
private val name: TextView = itemView.findViewById(R.id.budgetName)
private val description: TextView = itemView.findViewById(R.id.budgetDescription)
// private val balance: TextView = itemView.findViewById(R.id.budgetBalance)
override fun onBind(item: BindableData<Budget>) {
val budget = item.data
name.text = budget.name
if (budget.description.isNullOrBlank()) {
description.visibility = View.GONE
} else {
description.visibility = View.VISIBLE
description.text = budget.description
}
itemView.setOnClickListener {
val bundle = Bundle().apply {
putLong(EXTRA_BUDGET_ID, budget.id!!)
}
navController.navigate(R.id.categoryListFragment, bundle)
}
}
}

View file

@ -1,23 +0,0 @@
package com.wbrawner.budget.ui.budgets
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import com.wbrawner.budget.AsyncState
import com.wbrawner.budget.AsyncViewModel
import com.wbrawner.budget.common.budget.Budget
import com.wbrawner.budget.common.budget.BudgetRepository
import com.wbrawner.budget.launch
import javax.inject.Inject
class BudgetListViewModel : ViewModel(), AsyncViewModel<List<Budget>> {
override val state: MutableLiveData<AsyncState<List<Budget>>> = MutableLiveData(AsyncState.Loading)
@Inject
lateinit var budgetRepo: BudgetRepository
fun getBudgets() {
launch {
budgetRepo.findAll().toList()
}
}
}

View file

@ -1,121 +0,0 @@
package com.wbrawner.budget.ui.categories
import android.content.Context
import android.os.Build
import android.os.Bundle
import android.view.*
import android.widget.Toast
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.Observer
import androidx.navigation.fragment.findNavController
import com.wbrawner.budget.AllowanceApplication
import com.wbrawner.budget.AsyncState
import com.wbrawner.budget.R
import com.wbrawner.budget.ui.EXTRA_BUDGET_ID
import com.wbrawner.budget.ui.EXTRA_CATEGORY_ID
import com.wbrawner.budget.ui.EXTRA_CATEGORY_NAME
import com.wbrawner.budget.ui.toAmountSpannable
import com.wbrawner.budget.ui.transactions.TransactionListFragment
import kotlinx.android.synthetic.main.fragment_category_details.*
/**
* A simple [Fragment] subclass.
*/
class CategoryDetailsFragment : Fragment() {
val viewModel: CategoryDetailsViewModel by viewModels()
override fun onAttach(context: Context) {
(requireActivity().application as AllowanceApplication).appComponent.inject(viewModel)
super.onAttach(context)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setHasOptionsMenu(true)
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? =
inflater.inflate(R.layout.fragment_category_details, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
activity?.title = arguments?.getString(EXTRA_CATEGORY_NAME)
viewModel.state.observe(viewLifecycleOwner, Observer { state ->
when (state) {
is AsyncState.Loading -> {
categoryDetails.visibility = View.GONE
progressBar.visibility = View.VISIBLE
}
is AsyncState.Success -> {
categoryDetails.visibility = View.VISIBLE
progressBar.visibility = View.GONE
val category = state.data.category
activity?.title = category.title
val tintColor = if (category.expense) R.color.colorTextRed else R.color.colorTextGreen
val colorStateList = with(view.context) {
android.content.res.ColorStateList.valueOf(getColor(tintColor))
}
categoryProgress.progressTintList = colorStateList
categoryProgress.max = category.amount.toInt()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
categoryProgress.setProgress(
state.data.balance.toInt(),
true
)
} else {
categoryProgress.progress = state.data.balance.toInt()
}
total.text = category.amount.toAmountSpannable()
balance.text = state.data.balance.toAmountSpannable()
remaining.text = state.data.remaining.toAmountSpannable()
if (category.description.isNullOrBlank()) {
categoryDescription.visibility = View.GONE
} else {
categoryDescription.visibility = View.VISIBLE
categoryDescription.text = category.description
}
childFragmentManager.fragments.firstOrNull()?.let {
if (it !is TransactionListFragment) return@let
it.reloadItems()
} ?: run {
val transactionsFragment = TransactionListFragment().apply {
arguments = Bundle().apply {
putLong(EXTRA_BUDGET_ID, category.budgetId)
putLong(EXTRA_CATEGORY_ID, category.id!!)
}
}
childFragmentManager.beginTransaction()
.replace(R.id.transactionsFragmentContainer, transactionsFragment)
.commit()
}
}
is AsyncState.Error -> {
categoryDetails.visibility = View.VISIBLE
progressBar.visibility = View.GONE
Toast.makeText(view.context, "Failed to load context", Toast.LENGTH_SHORT).show()
}
is AsyncState.Exit -> {
findNavController().navigateUp()
}
}
})
viewModel.getCategory(arguments?.getLong(EXTRA_CATEGORY_ID))
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == R.id.action_edit) {
val bundle = Bundle().apply {
putLong(EXTRA_CATEGORY_ID, arguments?.getLong(EXTRA_CATEGORY_ID) ?: -1)
}
findNavController().navigate(R.id.addEditCategoryActivity, bundle)
} else if (item.itemId == android.R.id.home) {
return findNavController().navigateUp()
}
return super.onOptionsItemSelected(item)
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.menu_editable, menu)
}
}

View file

@ -1,40 +0,0 @@
package com.wbrawner.budget.ui.categories
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import com.wbrawner.budget.AsyncState
import com.wbrawner.budget.AsyncViewModel
import com.wbrawner.budget.common.category.Category
import com.wbrawner.budget.common.category.CategoryRepository
import com.wbrawner.budget.launch
import javax.inject.Inject
class CategoryDetailsViewModel : ViewModel(), AsyncViewModel<CategoryDetails> {
override val state: MutableLiveData<AsyncState<CategoryDetails>> = MutableLiveData(AsyncState.Loading)
@Inject
lateinit var categoryRepo: CategoryRepository
fun getCategory(id: Long? = null) {
if (id == null) {
state.postValue(AsyncState.Error("Invalid category ID"))
return
}
launch {
val category = categoryRepo.findById(id)
val multiplier = if (category.expense) -1 else 1
val balance = categoryRepo.getBalance(category.id!!) * multiplier
CategoryDetails(
category,
balance,
category.amount - balance
)
}
}
}
data class CategoryDetails(
val category: Category,
val balance: Long,
val remaining: Long
)

View file

@ -1,129 +0,0 @@
package com.wbrawner.budget.ui.categories
import android.content.Intent
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.widget.ArrayAdapter
import android.widget.Toast
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.NavUtils
import androidx.core.app.TaskStackBuilder
import androidx.lifecycle.Observer
import com.wbrawner.budget.AllowanceApplication
import com.wbrawner.budget.AsyncState
import com.wbrawner.budget.R
import com.wbrawner.budget.common.budget.Budget
import com.wbrawner.budget.common.category.Category
import com.wbrawner.budget.ui.EXTRA_CATEGORY_ID
import com.wbrawner.budget.ui.transactions.toLong
import kotlinx.android.synthetic.main.activity_add_edit_category.*
class CategoryFormActivity : AppCompatActivity() {
val viewModel: CategoryFormViewModel by viewModels()
var id: Long? = null
var menu: Menu? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_add_edit_category)
setSupportActionBar(action_bar)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
(application as AllowanceApplication).appComponent.inject(viewModel)
viewModel.state.observe(this, Observer { state ->
when (state) {
is AsyncState.Loading -> {
categoryForm.visibility = View.GONE
progressBar.visibility = View.VISIBLE
}
is AsyncState.Success -> {
categoryForm.visibility = View.VISIBLE
progressBar.visibility = View.GONE
val category = state.data.category
id = category.id
setTitle(state.data.titleRes)
menu?.findItem(R.id.action_delete)?.isVisible = state.data.showDeleteButton
edit_category_name.setText(category.title)
edit_category_amount.setText(String.format("%.02f", (category.amount.toBigDecimal() / 100.toBigDecimal()).toFloat()))
expense.isChecked = category.expense
income.isChecked = !category.expense
archived.isChecked = category.archived
budgetSpinner.adapter = ArrayAdapter<Budget>(
this@CategoryFormActivity,
android.R.layout.simple_list_item_1,
state.data.budgets
)
}
is AsyncState.Error -> {
// TODO: Show error message
categoryForm.visibility = View.VISIBLE
progressBar.visibility = View.GONE
Toast.makeText(this, "Failed to save Category", Toast.LENGTH_SHORT).show()
}
is AsyncState.Exit -> finish()
}
})
viewModel.loadCategory(intent?.extras?.getLong(EXTRA_CATEGORY_ID))
}
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
menuInflater.inflate(R.menu.menu_add_edit, menu)
if (id != null) {
menu?.findItem(R.id.action_delete)?.isVisible = true
}
this.menu = menu
return true
}
override fun onOptionsItemSelected(item: MenuItem?): Boolean {
when (item?.itemId) {
android.R.id.home -> {
val upIntent: Intent? = NavUtils.getParentActivityIntent(this)
when {
upIntent == null -> throw IllegalStateException("No Parent Activity Intent")
NavUtils.shouldUpRecreateTask(this, upIntent) || isTaskRoot -> {
TaskStackBuilder.create(this)
.addNextIntentWithParentStack(upIntent)
.startActivities()
}
else -> {
NavUtils.navigateUpTo(this, upIntent)
}
}
}
R.id.action_save -> {
if (!validateFields()) return true
viewModel.saveCategory(Category(
id = id,
title = edit_category_name.text.toString(),
amount = edit_category_amount.text.toLong(),
budgetId = (budgetSpinner.selectedItem as Budget).id!!,
expense = expense.isChecked,
archived = archived.isChecked
))
}
R.id.action_delete -> {
viewModel.deleteCategoryById(this@CategoryFormActivity.id!!)
}
}
return true
}
private fun validateFields(): Boolean {
var errors = false
if (edit_category_name.text?.isEmpty() == true) {
edit_category_name.error = getString(R.string.required_field_name)
errors = true
}
if (edit_category_amount.text.toString().isEmpty()) {
edit_category_amount.error = getString(R.string.required_field_amount)
errors = true
}
return !errors
}
}

View file

@ -1,79 +0,0 @@
package com.wbrawner.budget.ui.categories
import androidx.annotation.StringRes
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.wbrawner.budget.AsyncState
import com.wbrawner.budget.AsyncViewModel
import com.wbrawner.budget.R
import com.wbrawner.budget.common.budget.Budget
import com.wbrawner.budget.common.budget.BudgetRepository
import com.wbrawner.budget.common.category.Category
import com.wbrawner.budget.common.category.CategoryRepository
import com.wbrawner.budget.launch
import kotlinx.coroutines.launch
import javax.inject.Inject
class CategoryFormViewModel : ViewModel(), AsyncViewModel<CategoryFormState> {
override val state: MutableLiveData<AsyncState<CategoryFormState>> = MutableLiveData(AsyncState.Loading)
@Inject
lateinit var categoryRepository: CategoryRepository
@Inject
lateinit var budgetRepository: BudgetRepository
fun loadCategory(categoryId: Long? = null) {
launch {
val category = categoryId?.let {
categoryRepository.findById(it)
} ?: Category(-1, title = "", amount = 0)
CategoryFormState(
category,
budgetRepository.findAll().toList()
)
}
}
fun saveCategory(category: Category) {
viewModelScope.launch {
state.postValue(AsyncState.Loading)
try {
if (category.id == null)
categoryRepository.create(category)
else
categoryRepository.update(category)
state.postValue(AsyncState.Exit)
} catch (e: Exception) {
state.postValue(AsyncState.Error(e))
}
}
}
fun deleteCategoryById(id: Long) {
viewModelScope.launch {
state.postValue(AsyncState.Loading)
try {
categoryRepository.delete(id)
state.postValue(AsyncState.Exit)
} catch (e: Exception) {
state.postValue(AsyncState.Error(e))
}
}
}
}
data class CategoryFormState(
val category: Category,
val budgets: List<Budget>,
@StringRes val titleRes: Int,
val showDeleteButton: Boolean
) {
constructor(category: Category, budgets: List<Budget>) : this(
category,
budgets,
category.id?.let { R.string.title_edit_category } ?: R.string.title_add_category,
category.id != null
)
}

View file

@ -1,91 +0,0 @@
package com.wbrawner.budget.ui.categories
import android.annotation.SuppressLint
import android.content.Intent
import android.os.Bundle
import android.view.View
import android.widget.ProgressBar
import android.widget.TextView
import androidx.fragment.app.viewModels
import androidx.navigation.NavController
import androidx.navigation.fragment.findNavController
import com.wbrawner.budget.AllowanceApplication
import com.wbrawner.budget.R
import com.wbrawner.budget.common.category.Category
import com.wbrawner.budget.ui.EXTRA_CATEGORY_ID
import com.wbrawner.budget.ui.EXTRA_CATEGORY_NAME
import com.wbrawner.budget.ui.base.BindableAdapter
import com.wbrawner.budget.ui.base.BindableData
import com.wbrawner.budget.ui.base.ListWithAddButtonFragment
import kotlinx.coroutines.launch
class CategoryListFragment : ListWithAddButtonFragment<Category, CategoryListViewModel>() {
override val noItemsStringRes: Int = R.string.categories_no_data
override val viewModel: CategoryListViewModel by viewModels()
override fun reloadItems() {
viewModel.getCategories(viewLifecycleOwner)
}
override fun bindData(data: Category): BindableData<Category> = BindableData(data, CATEGORY_VIEW)
override val constructors: Map<Int, (View) -> BindableAdapter.BindableViewHolder<Category>> = mapOf(CATEGORY_VIEW to { v -> CategoryViewHolder(v, viewModel, findNavController()) })
override fun onCreate(savedInstanceState: Bundle?) {
(requireActivity().application as AllowanceApplication).appComponent.inject(viewModel)
super.onCreate(savedInstanceState)
}
override fun addItem() {
startActivity(Intent(activity, CategoryFormActivity::class.java))
}
companion object {
const val TAG_FRAGMENT = "categories"
}
}
const val CATEGORY_VIEW = R.layout.list_item_category
class CategoryViewHolder(
itemView: View,
private val viewModel: CategoryListViewModel,
private val navController: NavController
) : BindableAdapter.CoroutineViewHolder<Category>(itemView) {
private val name: TextView = itemView.findViewById(R.id.category_title)
private val amount: TextView = itemView.findViewById(R.id.category_amount)
private val progressBar: ProgressBar = itemView.findViewById(R.id.category_progress)
@SuppressLint("NewApi")
override fun onBind(item: BindableData<Category>) {
val category = item.data
name.text = category.title
// TODO: Format according to budget's currency
amount.text = String.format("${'$'}%.02f", category.amount / 100.0f)
val tintColor = if (category.expense) R.color.colorTextRed else R.color.colorTextGreen
val colorStateList = with(itemView.context) {
android.content.res.ColorStateList.valueOf(getColor(tintColor))
}
progressBar.progressTintList = colorStateList
progressBar.indeterminateTintList = colorStateList
progressBar.max = category.amount.toInt()
launch {
val balance = viewModel.getBalance(category).toInt()
progressBar.isIndeterminate = false
progressBar.setProgress(
balance,
true
)
amount.text = itemView.context.getString(
R.string.balance_remaning,
(category.amount - balance) / 100.0f
)
}
itemView.setOnClickListener {
val bundle = Bundle().apply {
putLong(EXTRA_CATEGORY_ID, category.id ?: -1)
putString(EXTRA_CATEGORY_NAME, category.title)
}
navController.navigate(R.id.categoryFragment, bundle)
}
}
}

View file

@ -1,41 +0,0 @@
package com.wbrawner.budget.ui.categories
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModel
import com.wbrawner.budget.AsyncState
import com.wbrawner.budget.AsyncViewModel
import com.wbrawner.budget.common.budget.BudgetRepository
import com.wbrawner.budget.common.category.Category
import com.wbrawner.budget.common.category.CategoryRepository
import com.wbrawner.budget.launch
import javax.inject.Inject
class CategoryListViewModel : ViewModel(), AsyncViewModel<List<Category>> {
override val state: MutableLiveData<AsyncState<List<Category>>> = MutableLiveData(AsyncState.Loading)
@Inject
lateinit var budgetRepo: BudgetRepository
@Inject
lateinit var categoryRepo: CategoryRepository
fun getCategories(lifecycleOwner: LifecycleOwner) {
budgetRepo.currentBudget.observe(lifecycleOwner, Observer {
val budgetId = budgetRepo.currentBudget.value?.id
if (budgetId == null) {
state.postValue(AsyncState.Error("Invalid budget ID"))
return@Observer
}
launch {
categoryRepo.findAll(arrayOf(budgetId)).toList()
}
})
}
suspend fun getBalance(category: Category): Long {
val multiplier = if (category.expense) -1 else 1
return categoryRepo.getBalance(category.id!!) * multiplier
}
}

View file

@ -1,30 +0,0 @@
package com.wbrawner.budget.ui.overview
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import com.wbrawner.budget.R
import com.wbrawner.budget.common.budget.Budget
class AccountsAdapter() : RecyclerView.Adapter<AccountsAdapter.AccountViewHolder>() {
private val accounts = mutableListOf<Budget>()
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = AccountViewHolder(
LayoutInflater.from(parent.context!!).inflate(R.layout.list_item_budget, parent, false)
)
override fun getItemCount(): Int = accounts.size
override fun onBindViewHolder(holder: AccountViewHolder, position: Int) {
val account = accounts[holder.adapterPosition]
holder.title.text = account.name
holder.balance.text = account.name
}
class AccountViewHolder(
itemView: View,
val title: TextView = itemView.findViewById(R.id.budgetName),
val balance: TextView = itemView.findViewById(R.id.budgetBalance)
) : RecyclerView.ViewHolder(itemView)
}

View file

@ -1,65 +0,0 @@
package com.wbrawner.budget.ui.overview
import android.content.Context
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.Observer
import com.wbrawner.budget.AllowanceApplication
import com.wbrawner.budget.AsyncState
import com.wbrawner.budget.R
import com.wbrawner.budget.ui.toAmountSpannable
import kotlinx.android.synthetic.main.fragment_overview.*
class OverviewFragment : Fragment() {
val viewModel: OverviewViewModel by viewModels()
override fun onAttach(context: Context) {
(requireActivity().application as AllowanceApplication).appComponent.inject(viewModel)
super.onAttach(context)
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? = inflater.inflate(R.layout.fragment_overview, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
viewModel.state.observe(viewLifecycleOwner, Observer { state ->
when (state) {
is AsyncState.Loading -> {
overviewContent.visibility = View.GONE
noData.visibility = View.GONE
progressBar.visibility = View.VISIBLE
}
is AsyncState.Success -> {
overviewContent.visibility = View.VISIBLE
noData.visibility = View.GONE
progressBar.visibility = View.GONE
activity?.title = state.data.budget.name
balance.text = state.data.balance.toAmountSpannable(view.context)
}
is AsyncState.Error -> {
overviewContent.visibility = View.GONE
progressBar.visibility = View.GONE
noData.visibility = View.VISIBLE
Log.e("OverviewFragment", "Failed to load overview", state.exception)
}
}
})
}
override fun onStart() {
super.onStart()
viewModel.loadOverview(viewLifecycleOwner)
}
companion object {
const val EXTRA_BUDGET_ID = "budgetId"
}
}

View file

@ -1,55 +0,0 @@
package com.wbrawner.budget.ui.overview
import android.util.Log
import androidx.lifecycle.*
import com.wbrawner.budget.AsyncState
import com.wbrawner.budget.common.budget.Budget
import com.wbrawner.budget.common.budget.BudgetRepository
import com.wbrawner.budget.common.transaction.TransactionRepository
import kotlinx.coroutines.launch
import javax.inject.Inject
class OverviewViewModel : ViewModel() {
val state = MutableLiveData<AsyncState<OverviewState>>(AsyncState.Loading)
@Inject
lateinit var budgetRepo: BudgetRepository
@Inject
lateinit var transactionRepo: TransactionRepository
fun loadOverview(lifecycleOwner: LifecycleOwner) {
budgetRepo.currentBudget.observe(lifecycleOwner, Observer { budget ->
if (budget == null) {
state.postValue(AsyncState.Error("Invalid Budget ID"))
return@Observer
}
viewModelScope.launch {
state.postValue(AsyncState.Loading)
try {
// TODO: Load expected and actual income/expense amounts as well
var balance = 0L
transactionRepo.findAll(listOf(budget.id!!)).forEach {
Log.d("OverviewViewModel", "${it.title} - ${it.amount}")
if (it.expense) {
balance -= it.amount
} else {
balance += it.amount
}
}
state.postValue(AsyncState.Success(OverviewState(
budget,
balance
)))
} catch (e: Exception) {
state.postValue(AsyncState.Error(e))
}
}
})
}
}
data class OverviewState(
val budget: Budget,
val balance: Long
)

View file

@ -1,23 +0,0 @@
package com.wbrawner.budget.ui.profile
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import com.wbrawner.budget.R
/**
* A simple [Fragment] subclass.
*/
class ProfileFragment : Fragment() {
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?): View? {
// Inflate the layout for this fragment
return inflater.inflate(R.layout.fragment_profile, container, false)
}
}

View file

@ -1,242 +0,0 @@
package com.wbrawner.budget.ui.transactions
import android.app.DatePickerDialog
import android.app.TimePickerDialog
import android.content.Intent
import android.os.Bundle
import android.text.Editable
import android.text.format.DateFormat
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.widget.AdapterView
import android.widget.ArrayAdapter
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.NavUtils
import androidx.core.app.TaskStackBuilder
import com.wbrawner.budget.AllowanceApplication
import com.wbrawner.budget.R
import com.wbrawner.budget.common.budget.Budget
import com.wbrawner.budget.common.category.Category
import com.wbrawner.budget.common.transaction.Transaction
import com.wbrawner.budget.ui.EXTRA_TRANSACTION_ID
import com.wbrawner.budget.ui.MainActivity
import kotlinx.android.synthetic.main.activity_add_edit_transaction.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.math.BigDecimal
import java.util.*
import kotlin.coroutines.CoroutineContext
class TransactionFormActivity : AppCompatActivity(), CoroutineScope {
override val coroutineContext: CoroutineContext = Dispatchers.Main
private val viewModel: TransactionFormViewModel by viewModels()
var id: Long? = null
var menu: Menu? = null
var transaction: Transaction? = null
@Suppress("DEPRECATION")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_add_edit_transaction)
setSupportActionBar(action_bar)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
setTitle(R.string.title_add_transaction)
edit_transaction_type_expense.isChecked = true
(application as AllowanceApplication).appComponent.inject(viewModel)
viewModel.init()
launch {
val accounts = viewModel.getAccounts().toTypedArray()
setCategories()
budgetSpinner.adapter = ArrayAdapter<Budget>(
this@TransactionFormActivity,
android.R.layout.simple_list_item_1,
accounts
)
container_edit_transaction_type.setOnCheckedChangeListener { _, _ ->
this@TransactionFormActivity.launch {
val budget = budgetSpinner.selectedItem as Budget
setCategories(viewModel.getCategories(budget.id!!, edit_transaction_type_expense.isChecked))
}
}
budgetSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onNothingSelected(parent: AdapterView<*>?) {
}
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
this@TransactionFormActivity.launch {
val budget = budgetSpinner.selectedItem as Budget
setCategories(viewModel.getCategories(budget.id!!, edit_transaction_type_expense.isChecked))
}
}
}
loadTransaction()
transactionDate.setOnClickListener {
val currentDate = DateFormat.getDateFormat(this@TransactionFormActivity)
.parse(transactionDate.text.toString()) ?: Date()
DatePickerDialog(
this@TransactionFormActivity,
{ _, year, month, dayOfMonth ->
transactionDate.text = DateFormat.getDateFormat(this@TransactionFormActivity)
.format(Date(year, month, dayOfMonth))
},
currentDate.year + 1900,
currentDate.month,
currentDate.date
)
.show()
}
transactionTime.setOnClickListener {
val currentDate = DateFormat.getTimeFormat(this@TransactionFormActivity)
.parse(transactionTime.text.toString()) ?: Date()
TimePickerDialog(
this@TransactionFormActivity,
{ _, hourOfDay, minute ->
val newTime = Date().apply {
hours = hourOfDay
minutes = minute
}
transactionTime.text = DateFormat.getTimeFormat(this@TransactionFormActivity)
.format(newTime)
},
currentDate.hours,
currentDate.minutes,
DateFormat.is24HourFormat(this@TransactionFormActivity)
).show()
}
}
}
private suspend fun loadTransaction() {
transaction = try {
viewModel.getTransaction(intent!!.extras!!.getLong(EXTRA_TRANSACTION_ID))
} catch (e: Exception) {
menu?.findItem(R.id.action_delete)?.isVisible = false
val date = Date()
transactionDate.text = DateFormat.getDateFormat(this).format(date)
transactionTime.text = DateFormat.getTimeFormat(this).format(date)
return
}
setTitle(R.string.title_edit_transaction)
id = transaction?.id
menu?.findItem(R.id.action_delete)?.isVisible = true
edit_transaction_title.setText(transaction?.title)
edit_transaction_description.setText(transaction?.description)
edit_transaction_amount.setText(String.format("%.02f", transaction!!.amount / 100.0f))
if (transaction!!.expense) {
edit_transaction_type_expense.isChecked = true
} else {
edit_transaction_type_income.isChecked = true
}
transactionDate.text = DateFormat.getDateFormat(this).format(transaction!!.date)
transactionTime.text = DateFormat.getTimeFormat(this).format(transaction!!.date)
transaction?.categoryId?.let {
for (i in 0 until edit_transaction_category.adapter.count) {
if (it == (edit_transaction_category.adapter.getItem(i) as Category).id) {
edit_transaction_category.setSelection(i)
break
}
}
}
}
private fun setCategories(categories: List<Category> = emptyList()) {
val adapter = ArrayAdapter<Category>(
this@TransactionFormActivity,
android.R.layout.simple_list_item_1
)
adapter.add(Category(id = 0, title = getString(R.string.uncategorized),
amount = 0, budgetId = 0))
adapter.addAll(categories)
edit_transaction_category.adapter = adapter
transaction?.categoryId?.let {
for (i in 0 until edit_transaction_category.adapter.count) {
if (it == (edit_transaction_category.adapter.getItem(i) as Category).id) {
edit_transaction_category.setSelection(i)
break
}
}
}
}
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
menuInflater.inflate(R.menu.menu_add_edit, menu)
if (id != null) {
menu?.findItem(R.id.action_delete)?.isVisible = true
}
this.menu = menu
return true
}
override fun onOptionsItemSelected(item: MenuItem?): Boolean {
when (item?.itemId) {
android.R.id.home -> onNavigateUp()
R.id.action_save -> {
val date = GregorianCalendar.getInstance().apply {
DateFormat.getDateFormat(this@TransactionFormActivity)
.parse(transactionDate.text.toString())
?.let {
time = it
}
DateFormat.getTimeFormat(this@TransactionFormActivity)
.parse(transactionTime.text.toString())
?.let { GregorianCalendar.getInstance().apply { time = it } }
?.let {
set(Calendar.HOUR_OF_DAY, it.get(Calendar.HOUR_OF_DAY))
set(Calendar.MINUTE, it.get(Calendar.MINUTE))
}
}
val categoryId = (edit_transaction_category.selectedItem as? Category)?.id
?.let {
if (it > 0) it
else null
}
launch {
viewModel.saveTransaction(Transaction(
id = id,
budgetId = (budgetSpinner.selectedItem as Budget).id!!,
title = edit_transaction_title.text.toString(),
date = date.time,
description = edit_transaction_description.text.toString(),
amount = (BigDecimal(edit_transaction_amount.text.toString()) * 100.toBigDecimal()).toLong(),
expense = edit_transaction_type_expense.isChecked,
categoryId = categoryId,
createdBy = viewModel.currentUserId!!
))
onNavigateUp()
}
}
R.id.action_delete -> {
launch {
viewModel.deleteTransaction(this@TransactionFormActivity.id!!)
onNavigateUp()
}
}
}
return true
}
override fun onNavigateUp(): Boolean {
val upIntent: Intent = NavUtils.getParentActivityIntent(this)
?: throw IllegalStateException("No Parent Activity Intent")
upIntent.putExtra(MainActivity.EXTRA_OPEN_FRAGMENT, TransactionListFragment.TAG_FRAGMENT)
when {
NavUtils.shouldUpRecreateTask(this, upIntent) || isTaskRoot -> {
TaskStackBuilder.create(this)
.addNextIntentWithParentStack(upIntent)
.startActivities()
}
else -> {
finish()
}
}
return true
}
}
fun Editable?.toLong(): Long = toString().toDouble().toLong() * 100

View file

@ -1,48 +0,0 @@
package com.wbrawner.budget.ui.transactions
import androidx.lifecycle.ViewModel
import com.wbrawner.budget.common.budget.BudgetRepository
import com.wbrawner.budget.common.category.CategoryRepository
import com.wbrawner.budget.common.transaction.Transaction
import com.wbrawner.budget.common.transaction.TransactionRepository
import com.wbrawner.budget.common.user.UserRepository
import javax.inject.Inject
class TransactionFormViewModel : ViewModel() {
@Inject
lateinit var budgetRepository: BudgetRepository
@Inject
lateinit var categoryRepository: CategoryRepository
@Inject
lateinit var transactionRepository: TransactionRepository
@Inject
lateinit var userRepository: UserRepository
var currentUserId: Long? = null
private set
//TODO: Find a better way to handle this
fun init() {
userRepository.currentUser.observeForever {
currentUserId = it?.id
}
}
suspend fun getCategories(budgetId: Long, expense: Boolean) = categoryRepository.findAll(arrayOf(budgetId)).filter {
it.expense == expense
}
suspend fun getTransaction(id: Long) = transactionRepository.findById(id)
suspend fun saveTransaction(transaction: Transaction) = if (transaction.id == null)
transactionRepository.create(transaction)
else
transactionRepository.update(transaction)
suspend fun deleteTransaction(id: Long) = transactionRepository.delete(id)
suspend fun getAccounts() = budgetRepository.findAll()
}

View file

@ -1,105 +0,0 @@
package com.wbrawner.budget.ui.transactions
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.widget.TextView
import androidx.appcompat.app.AlertDialog
import androidx.core.content.ContextCompat
import androidx.fragment.app.viewModels
import androidx.navigation.NavController
import androidx.navigation.fragment.findNavController
import com.wbrawner.budget.AllowanceApplication
import com.wbrawner.budget.R
import com.wbrawner.budget.common.transaction.Transaction
import com.wbrawner.budget.ui.EXTRA_BUDGET_ID
import com.wbrawner.budget.ui.EXTRA_CATEGORY_ID
import com.wbrawner.budget.ui.EXTRA_TRANSACTION_ID
import com.wbrawner.budget.ui.base.BindableAdapter
import com.wbrawner.budget.ui.base.BindableData
import com.wbrawner.budget.ui.base.ListWithAddButtonFragment
import java.text.SimpleDateFormat
class TransactionListFragment : ListWithAddButtonFragment<Transaction, TransactionListViewModel>() {
override val noItemsStringRes: Int = R.string.transactions_no_data
override val viewModel: TransactionListViewModel by viewModels()
override val constructors: Map<Int, (View) -> BindableAdapter.BindableViewHolder<Transaction>>
get() = mapOf(TRANSACTION_VIEW to { v -> TransactionViewHolder(v, findNavController()) })
override fun reloadItems() {
viewModel.getTransactions(categoryId = arguments?.getLong(EXTRA_CATEGORY_ID))
}
override fun bindData(data: Transaction): BindableData<Transaction> = BindableData(data, TRANSACTION_VIEW)
override fun onAttach(context: Context) {
(requireActivity().application as AllowanceApplication).appComponent.inject(viewModel)
super.onAttach(context)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setHasOptionsMenu(true)
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.menu_transaction_list, menu)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId != R.id.filter) {
return super.onOptionsItemSelected(item)
}
// TODO: Launch a Google Drive-style search/filter screen
AlertDialog.Builder(requireContext())
.setTitle("Filter Transactions")
.setPositiveButton(R.string.action_submit) { _, _ ->
reloadItems()
}
.setNegativeButton(R.string.action_cancel) { _, _ ->
// Do nothing
}
.create()
.show()
return true
}
override fun addItem() {
startActivity(Intent(activity, TransactionFormActivity::class.java))
}
companion object {
const val TAG_FRAGMENT = "transactions"
}
}
const val TRANSACTION_VIEW = R.layout.list_item_transaction
class TransactionViewHolder(itemView: View, val navController: NavController) : BindableAdapter.CoroutineViewHolder<Transaction>(itemView) {
private val name: TextView = itemView.findViewById(R.id.transaction_title)
private val date: TextView = itemView.findViewById(R.id.transaction_date)
private val amount: TextView = itemView.findViewById(R.id.transaction_amount)
@SuppressLint("NewApi")
override fun onBind(item: BindableData<Transaction>) {
val transaction = item.data
name.text = transaction.title
date.text = SimpleDateFormat.getDateInstance(SimpleDateFormat.SHORT).format(transaction.date)
amount.text = String.format("${'$'}%.02f", transaction.amount / 100.0f)
val context = itemView.context
val color = if (transaction.expense) R.color.colorTextRed else R.color.colorTextGreen
amount.setTextColor(ContextCompat.getColor(context, color))
itemView.setOnClickListener {
val bundle = Bundle().apply {
putLong(EXTRA_BUDGET_ID, transaction.budgetId)
putLong(EXTRA_TRANSACTION_ID, transaction.id ?: -1)
}
navController.navigate(R.id.addEditTransactionActivity, bundle)
}
}
}

View file

@ -1,36 +0,0 @@
package com.wbrawner.budget.ui.transactions
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import com.wbrawner.budget.AsyncState
import com.wbrawner.budget.AsyncViewModel
import com.wbrawner.budget.common.budget.BudgetRepository
import com.wbrawner.budget.common.transaction.Transaction
import com.wbrawner.budget.common.transaction.TransactionRepository
import com.wbrawner.budget.launch
import java.util.*
import javax.inject.Inject
class TransactionListViewModel : ViewModel(), AsyncViewModel<List<Transaction>> {
@Inject
lateinit var budgetRepository: BudgetRepository
@Inject
lateinit var transactionRepo: TransactionRepository
override val state: MutableLiveData<AsyncState<List<Transaction>>> = MutableLiveData(AsyncState.Loading)
fun getTransactions(
categoryId: Long? = null,
start: Calendar? = null,
end: Calendar? = null
) {
budgetRepository.currentBudget.observeForever { budget ->
val budgets = budget?.id?.let { listOf(it) }
val categories = categoryId?.let { listOf(it) }
launch {
transactionRepo.findAll(budgets, categories, start, end).toList()
}
}
}
}

View file

@ -1,68 +0,0 @@
package com.wbrawner.budget.util
import android.app.Application
import android.app.job.JobInfo
import android.util.Log
import com.wbrawner.budget.BuildConfig
import com.wbrawner.budget.common.util.ErrorHandler
import org.acra.ACRA
import org.acra.ReportField
import org.acra.config.CoreConfigurationBuilder
import org.acra.config.HttpSenderConfigurationBuilder
import org.acra.config.SchedulerConfigurationBuilder
import org.acra.data.StringFormat
import org.acra.sender.HttpSender
class AcraErrorHandler : ErrorHandler {
override fun init(application: Application) {
if (BuildConfig.ACRA_URL.isBlank()
|| BuildConfig.ACRA_USER.isBlank()
|| BuildConfig.ACRA_PASS.isBlank()) {
return
}
val builder = CoreConfigurationBuilder(application)
.setBuildConfigClass(BuildConfig::class.java)
.setReportFormat(StringFormat.JSON)
.setReportContent(
ReportField.ANDROID_VERSION,
ReportField.APP_VERSION_CODE,
ReportField.APP_VERSION_NAME,
ReportField.APPLICATION_LOG,
ReportField.AVAILABLE_MEM_SIZE,
ReportField.BRAND,
ReportField.BUILD_CONFIG,
ReportField.CRASH_CONFIGURATION,
ReportField.CUSTOM_DATA, // Not currently used, but might be useful in the future
ReportField.INITIAL_CONFIGURATION,
ReportField.PACKAGE_NAME,
ReportField.PHONE_MODEL,
ReportField.SHARED_PREFERENCES,
ReportField.STACK_TRACE,
ReportField.STACK_TRACE_HASH,
ReportField.THREAD_DETAILS,
ReportField.TOTAL_MEM_SIZE,
ReportField.USER_APP_START_DATE,
ReportField.USER_CRASH_DATE
)
builder.getPluginConfigurationBuilder(HttpSenderConfigurationBuilder::class.java)
.setUri(BuildConfig.ACRA_URL)
.setHttpMethod(HttpSender.Method.POST)
.setBasicAuthLogin(BuildConfig.ACRA_USER)
.setBasicAuthPassword(BuildConfig.ACRA_PASS)
.setEnabled(true)
builder.getPluginConfigurationBuilder(SchedulerConfigurationBuilder::class.java)
.setRequiresNetworkType(JobInfo.NETWORK_TYPE_UNMETERED)
.setRequiresBatteryNotLow(true)
.setEnabled(true)
ACRA.init(application, builder)
}
override fun reportException(t: Throwable, message: String?) {
@Suppress("ConstantConditionIf")
if (BuildConfig.DEBUG) {
Log.e("AcraErrorHandler", "Caught exception: $message", t)
}
ACRA.getErrorReporter().handleException(t)
}
}

View file

@ -0,0 +1,24 @@
package com.wbrawner.twigs.android
import android.util.Log
import com.wbrawner.twigs.shared.ErrorHandler
import com.wbrawner.twigs.shared.Store
import com.wbrawner.twigs.shared.create
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
@Module
@InstallIn(SingletonComponent::class)
class AppModule {
@Provides
fun provideErrorHandler(): ErrorHandler = object : ErrorHandler {
override fun reportException(t: Throwable, message: String?) {
Log.e("ErrorHandler", "Report exception: $message", t)
}
}
@Provides
fun providesStore() = Store.create()
}

View file

@ -0,0 +1,7 @@
package com.wbrawner.twigs.android
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class TwigsApplication : Application()

View file

@ -0,0 +1,239 @@
package com.wbrawner.twigs.android.ui
import android.os.Bundle
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.WindowCompat
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.wbrawner.twigs.android.R
import com.wbrawner.twigs.android.ui.auth.LoginScreen
import com.wbrawner.twigs.android.ui.base.TwigsApp
import com.wbrawner.twigs.android.ui.category.CategoriesScreen
import com.wbrawner.twigs.android.ui.category.CategoryDetailsScreen
import com.wbrawner.twigs.android.ui.recurringtransaction.RecurringTransactionDetailsScreen
import com.wbrawner.twigs.android.ui.recurringtransaction.RecurringTransactionsScreen
import com.wbrawner.twigs.android.ui.transaction.TransactionDetailsScreen
import com.wbrawner.twigs.android.ui.transaction.TransactionsScreen
import com.wbrawner.twigs.shared.Action
import com.wbrawner.twigs.shared.Route
import com.wbrawner.twigs.shared.Store
import com.wbrawner.twigs.shared.budget.BudgetAction
import com.wbrawner.twigs.shared.category.CategoryAction
import com.wbrawner.twigs.shared.recurringtransaction.RecurringTransactionAction
import com.wbrawner.twigs.shared.transaction.TransactionAction
import com.wbrawner.twigs.ui.AuthViewModel
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import javax.inject.Inject
@OptIn(ExperimentalAnimationApi::class)
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
@Inject
lateinit var store: Store
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
installSplashScreen()
WindowCompat.setDecorFitsSystemWindows(window, false)
setContent {
val state by store.state.collectAsState()
val navController = rememberNavController()
LaunchedEffect(state.route) {
navController.navigate(state.route.path)
}
TwigsApp {
val authViewModel: AuthViewModel = hiltViewModel()
BackHandler {
store.dispatch(Action.Back)
}
NavHost(navController, state.initialRoute.path) {
composable(Route.Login.path) {
LoginScreen(store = store, viewModel = authViewModel)
}
composable(Route.Overview.path) {
OverviewScreen(store = store)
}
composable(Route.Transactions().path) {
TransactionsScreen(store = store)
}
composable(
Route.Transactions(selected = "{id}").path,
arguments = listOf(navArgument("id") {
type = NavType.StringType
nullable = false
})
) {
TransactionDetailsScreen(store = store)
}
composable(Route.Categories().path) {
CategoriesScreen(store = store)
}
composable(
Route.Categories(selected = "{id}").path,
arguments = listOf(navArgument("id") {
type = NavType.StringType
nullable = false
})
) {
CategoryDetailsScreen(store = store)
}
composable(Route.RecurringTransactions().path) {
RecurringTransactionsScreen(store = store)
}
composable(
Route.RecurringTransactions(selected = "{id}").path,
arguments = listOf(navArgument("id") {
type = NavType.StringType
nullable = false
})
) {
RecurringTransactionDetailsScreen(store = store)
}
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TwigsScaffold(
store: Store,
title: String,
onClickFab: (() -> Unit)? = null,
actions: @Composable RowScope.() -> Unit = {},
drawerState: DrawerState = rememberDrawerState(initialValue = DrawerValue.Closed),
navigationIcon: @Composable () -> Unit = {
val coroutineScope = rememberCoroutineScope()
IconButton(onClick = {
coroutineScope.launch {
drawerState.open()
}
}) {
Icon(Icons.Default.Menu, "Main menu")
}
},
content: @Composable (padding: PaddingValues) -> Unit
) {
val state by store.state.collectAsState()
ModalNavigationDrawer(
drawerState = drawerState,
drawerContent = {
ModalDrawerSheet {
TwigsDrawer(store = store, drawerState::close)
}
}
) {
Scaffold(
topBar = {
TopAppBar(
navigationIcon = navigationIcon,
actions = actions,
title = {
Text(title)
}
)
},
bottomBar = {
NavigationBar {
NavigationBarItem(
selected = state.route == Route.Overview,
onClick = { store.dispatch(BudgetAction.OverviewClicked) },
icon = { Icon(Icons.Default.Dashboard, contentDescription = null) },
label = { Text(text = "Overview") }
)
NavigationBarItem(
selected = state.route is Route.Transactions,
onClick = { store.dispatch(TransactionAction.TransactionsClicked) },
icon = { Icon(Icons.Default.AttachMoney, contentDescription = null) },
label = { Text(text = "Transactions") }
)
NavigationBarItem(
selected = state.route is Route.Categories,
onClick = { store.dispatch(CategoryAction.CategoriesClicked) },
icon = { Icon(Icons.Default.Category, contentDescription = null) },
label = { Text(text = "Categories") }
)
NavigationBarItem(
selected = state.route is Route.RecurringTransactions,
onClick = { store.dispatch(RecurringTransactionAction.RecurringTransactionsClicked) },
icon = { Icon(Icons.Default.Repeat, contentDescription = null) },
label = { Text(text = "Recurring") }
)
}
},
floatingActionButton = {
onClickFab?.let { onClick ->
FloatingActionButton(onClick = onClick) {
Icon(imageVector = Icons.Default.Add, "Add")
}
}
}
) {
content(it)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TwigsDrawer(store: Store, close: suspend () -> Unit) {
val state by store.state.collectAsState()
val coroutineScope = rememberCoroutineScope()
Column(modifier = Modifier.fillMaxSize(), verticalArrangement = spacedBy(8.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
val image =
if (isSystemInDarkTheme()) R.drawable.ic_twigs_outline else R.drawable.ic_twigs_color
Image(painter = painterResource(id = image), null)
Text(
text = "twigs",
style = MaterialTheme.typography.titleLarge
)
}
state.budgets?.let { budgets ->
LazyColumn(modifier = Modifier.fillMaxHeight()) {
items(budgets) { budget ->
val selected = budget.id == state.selectedBudget
val icon = if (selected) Icons.Filled.Folder else Icons.Default.Folder
NavigationDrawerItem(
icon = { Icon(icon, contentDescription = null) },
label = { Text(budget.name) },
selected = selected,
onClick = {
store.dispatch(BudgetAction.SelectBudget(budget.id))
coroutineScope.launch {
close()
}
}
)
}
}
}
}
}

View file

@ -0,0 +1,361 @@
package com.wbrawner.twigs.android.ui
import android.content.res.Configuration.UI_MODE_NIGHT_YES
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.scrollable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.wbrawner.twigs.android.ui.base.TwigsColors
import com.wbrawner.twigs.android.ui.base.TwigsTheme
import com.wbrawner.twigs.android.ui.transaction.toCurrencyString
import com.wbrawner.twigs.shared.Store
import com.wbrawner.twigs.shared.budget.BudgetAction
import kotlinx.datetime.Month
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import java.time.format.TextStyle
import java.util.Locale
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun OverviewScreen(store: Store) {
val state by store.state.collectAsState()
val budget = state.selectedBudget?.let { id -> state.budgets?.first { it.id == id } }
TwigsScaffold(store = store, title = budget?.name ?: "Select a Budget") { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.scrollable(rememberScrollState(), orientation = Orientation.Vertical)
.padding(8.dp),
verticalArrangement = spacedBy(8.dp, alignment = Alignment.Top)
) {
budget?.description?.let { description ->
if (description.isNotBlank()) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
verticalArrangement = spacedBy(8.dp)
) {
Text(description, style = MaterialTheme.typography.titleMedium)
}
}
}
}
Card(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
val month =
remember(state.from) { state.from.toLocalDateTime(TimeZone.UTC).month }
val year =
remember(state.from) { state.from.toLocalDateTime(TimeZone.UTC).year }
var showMonthPicker by remember { mutableStateOf(false) }
LabeledField(
modifier = Modifier.clickable {
showMonthPicker = true
},
label = "Month",
value = "${
month.getDisplayName(
TextStyle.FULL,
LocalConfiguration.current.locales[0]
)
} $year"
)
LabeledField(
label = "Cash Flow",
value = state.budgetBalance?.toCurrencyString() ?: "-"
)
LabeledField(
label = "Transactions",
value = state.transactions?.size?.toString() ?: "-"
)
if (showMonthPicker) {
var monthField by remember { mutableStateOf(month) }
var yearField by remember { mutableStateOf(year.toString()) }
var yearError by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = { showMonthPicker = false },
confirmButton = {
TextButton({
if (!yearError) {
showMonthPicker = false
store.dispatch(
BudgetAction.SetDateRange(
monthField,
yearField.toInt()
)
)
}
}) {
Text("Change")
}
},
dismissButton = {
TextButton({
showMonthPicker = false
}) {
Text("Cancel")
}
},
title = {
Text("Select a month to view")
},
text = {
val (monthExpanded, setMonthExpanded) = remember {
mutableStateOf(false)
}
Row(
modifier = Modifier
.fillMaxWidth(),
horizontalArrangement = spacedBy(8.dp)
) {
ExposedDropdownMenuBox(
modifier = Modifier
.weight(1f),
expanded = monthExpanded,
onExpandedChange = setMonthExpanded,
) {
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
value = month.getDisplayName(
TextStyle.FULL,
Locale.getDefault()
),
label = {
Text("Month")
},
onValueChange = {},
readOnly = true,
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = monthExpanded)
}
)
ExposedDropdownMenu(
expanded = monthExpanded,
onDismissRequest = {
setMonthExpanded(false)
}) {
Month.values().forEach { m ->
DropdownMenuItem(
text = {
Text(
m.getDisplayName(
TextStyle.FULL,
Locale.getDefault()
)
)
},
onClick = {
monthField = m
setMonthExpanded(false)
}
)
}
}
}
OutlinedTextField(
modifier = Modifier.weight(1f),
value = yearField,
onValueChange = { value ->
yearField = value
value.toIntOrNull()?.let {
yearError = false
} ?: run {
yearError = true
}
},
keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Number),
label = {
Text("Year")
},
isError = yearError,
supportingText = {
if (yearError) {
Text(
"Invalid year",
color = MaterialTheme.colorScheme.error
)
}
}
)
}
}
)
}
}
}
CashFlowChart(
expectedIncome = state.expectedIncome,
actualIncome = state.actualIncome,
expectedExpenses = state.expectedExpenses,
actualExpenses = state.actualExpenses
)
}
}
}
@Composable
fun CashFlowChart(
expectedIncome: Long?,
actualIncome: Long?,
expectedExpenses: Long?,
actualExpenses: Long?,
) {
val maxValue = listOfNotNull(expectedIncome, expectedExpenses, actualIncome, actualExpenses)
.maxOrNull()
?.toFloat()
?: 0f
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
verticalArrangement = spacedBy(8.dp)
) {
CashFlowProgressBar(
label = "Expected Income",
value = expectedIncome,
maxValue = maxValue,
color = TwigsColors.DarkGreen,
trackColor = MaterialTheme.colorScheme.outline
)
CashFlowProgressBar(
label = "Actual Income",
value = actualIncome,
maxValue = maxValue,
color = TwigsColors.Green,
trackColor = MaterialTheme.colorScheme.outline
)
Spacer(modifier = Modifier.height(4.dp))
CashFlowProgressBar(
label = "Expected Expenses",
value = expectedExpenses,
maxValue = maxValue,
color = TwigsColors.DarkRed,
trackColor = MaterialTheme.colorScheme.outline
)
CashFlowProgressBar(
label = "Actual Expenses",
value = actualExpenses,
maxValue = maxValue,
color = TwigsColors.Red,
trackColor = MaterialTheme.colorScheme.outline
)
}
}
}
@Composable
fun CashFlowProgressBar(
label: String,
value: Long?,
maxValue: Float,
color: Color,
trackColor: Color
) {
Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = spacedBy(4.dp)) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(label)
Text(value?.toCurrencyString() ?: "-")
}
value?.let {
LinearProgressIndicator(
modifier = Modifier
.fillMaxWidth()
.height(8.dp)
.clip(RoundedCornerShape(4.dp)),
progress = it.toFloat() / maxValue,
color = color,
trackColor = trackColor,
)
} ?: LinearProgressIndicator(
modifier = Modifier
.fillMaxWidth()
.height(8.dp),
color = color,
trackColor = trackColor,
)
}
}
@Composable
fun LabeledField(modifier: Modifier = Modifier, label: String, value: String) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = spacedBy(4.dp)
) {
Text(text = label, style = MaterialTheme.typography.labelMedium)
Text(text = value, style = MaterialTheme.typography.bodyLarge)
}
}
@Preview
@Preview(uiMode = UI_MODE_NIGHT_YES)
@Composable
fun CashFlowChart_Preview() {
TwigsTheme {
CashFlowChart(
expectedIncome = 100,
actualIncome = 50,
expectedExpenses = 80,
actualExpenses = 95
)
}
}
@Preview
@Composable
fun LabeledField_Preview() {
TwigsTheme {
LabeledField(
label = "Transactions",
value = "250"
)
}
}

View file

@ -1,4 +1,4 @@
package com.wbrawner.budget.ui
package com.wbrawner.twigs.android.ui
import android.content.Context
import android.text.Spannable
@ -9,7 +9,7 @@ import android.widget.EditText
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.floatingactionbutton.FloatingActionButton
import com.wbrawner.budget.R
import com.wbrawner.twigs.android.R
import java.text.NumberFormat
import java.util.*

View file

@ -0,0 +1,19 @@
package com.wbrawner.twigs.ui
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import com.wbrawner.twigs.shared.Store
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
@HiltViewModel
class AuthViewModel @Inject constructor(
val store: Store
) : ViewModel() {
val server = mutableStateOf("")
val username = mutableStateOf("")
val email = mutableStateOf("")
val password = mutableStateOf("")
val confirmPassword = mutableStateOf("")
val enableLogin = mutableStateOf(false)
}

View file

@ -0,0 +1,288 @@
package com.wbrawner.twigs.android.ui.auth
import android.content.res.Configuration.UI_MODE_NIGHT_YES
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.Image
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.isShiftPressed
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.tooling.preview.Devices
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.wbrawner.twigs.android.R
import com.wbrawner.twigs.android.ui.base.TwigsApp
import com.wbrawner.twigs.shared.Effect
import com.wbrawner.twigs.shared.Store
import com.wbrawner.twigs.shared.user.ConfigAction
import com.wbrawner.twigs.ui.AuthViewModel
@ExperimentalAnimationApi
@Composable
fun LoginScreen(
store: Store,
viewModel: AuthViewModel
) {
val state by store.state.collectAsState()
val effect by store.effects.collectAsState(initial = Effect.Empty)
val (error, setError) = remember { mutableStateOf("") }
(effect as? Effect.Error)?.let {
setError(it.message)
} ?: setError("")
val (server, setServer) = viewModel.server
val (username, setUsername) = viewModel.username
val (password, setPassword) = viewModel.password
AnimatedVisibility(
visible = !state.loading,
enter = fadeIn(),
exit = fadeOut()
) {
LoginForm(
error,
server,
setServer,
username,
setUsername,
password,
setPassword,
true,
{ store.dispatch(ConfigAction.ForgotPasswordClicked) },
{ store.dispatch(ConfigAction.RegisterClicked) },
{
store.dispatch(ConfigAction.SetServer(server))
store.dispatch(
ConfigAction.Login(
viewModel.username.value,
viewModel.password.value
)
)
},
)
}
AnimatedVisibility(
visible = state.loading,
enter = fadeIn(),
exit = fadeOut()
) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class)
@Composable
fun LoginForm(
error: String,
server: String,
setServer: (String) -> Unit,
username: String,
setUsername: (String) -> Unit,
password: String,
setPassword: (String) -> Unit,
enableLogin: Boolean,
forgotPassword: () -> Unit,
register: () -> Unit,
login: () -> Unit,
) {
val scrollState = rememberScrollState()
val (serverInput, usernameInput, passwordInput, loginButton) = FocusRequester.createRefs()
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scrollState)
.padding(16.dp)
.imePadding(),
verticalArrangement = spacedBy(8.dp, Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Image(
painter = painterResource(id = if (isSystemInDarkTheme()) R.drawable.ic_twigs_outline else R.drawable.ic_twigs_color),
contentDescription = null
)
Text("Log in to manage your budgets")
if (error.isNotBlank()) {
Text(text = error, color = MaterialTheme.colorScheme.error)
}
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.focusRequester(serverInput)
.onPreviewKeyEvent {
if (it.type != KeyEventType.KeyDown) {
return@onPreviewKeyEvent false
}
if (it.key != Key.Tab) {
return@onPreviewKeyEvent false
}
if (it.isShiftPressed) {
return@onPreviewKeyEvent false
}
usernameInput.requestFocus()
true
},
value = server,
onValueChange = setServer,
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Uri,
capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.Next
),
placeholder = { Text("Server") },
keyboardActions = KeyboardActions(onNext = {
usernameInput.requestFocus()
}),
maxLines = 1
)
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.focusRequester(usernameInput)
.onPreviewKeyEvent {
if (it.type != KeyEventType.KeyDown) {
return@onPreviewKeyEvent false
}
if (it.key != Key.Tab) {
return@onPreviewKeyEvent false
}
if (it.isShiftPressed) {
serverInput.requestFocus()
} else {
passwordInput.requestFocus()
}
true
},
value = username,
onValueChange = setUsername,
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Text,
capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.Next
),
placeholder = { Text("Username") },
keyboardActions = KeyboardActions(onNext = {
passwordInput.requestFocus()
}),
maxLines = 1
)
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.focusRequester(passwordInput)
.onPreviewKeyEvent {
if (it.type != KeyEventType.KeyDown) {
return@onPreviewKeyEvent false
}
when (it.key) {
Key.Tab -> {
if (it.isShiftPressed) {
usernameInput.requestFocus()
} else {
loginButton.requestFocus()
}
true
}
Key.Enter -> {
login()
true
}
else -> false
}
},
value = password,
onValueChange = setPassword,
placeholder = { Text("Password") },
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Password,
capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(onDone = {
login()
}),
maxLines = 1
)
Button(
modifier = Modifier
.fillMaxWidth()
.focusRequester(loginButton),
enabled = enableLogin,
onClick = login
) {
Text("Login")
}
TextButton(
modifier = Modifier.fillMaxWidth(),
onClick = forgotPassword
) {
Text("Forgot password?")
}
OutlinedButton(
modifier = Modifier.fillMaxWidth(),
onClick = register
) {
Text("Need an account?")
}
}
}
@Composable
@Preview(showBackground = true)
@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_YES)
fun LoginScreen_Preview() {
TwigsApp {
LoginForm("", "", {}, "", {}, "", {}, false, {}, {}, { })
}
}
@Composable
@Preview(showBackground = true, device = Devices.PIXEL_C)
@Preview(showBackground = true, device = Devices.PIXEL_C, uiMode = UI_MODE_NIGHT_YES)
fun LoginScreen_PreviewTablet() {
TwigsApp {
LoginForm("", "", {}, "", {}, "", {}, false, {}, {}, { })
}
}

View file

@ -0,0 +1,94 @@
package com.wbrawner.twigs.android.ui.base
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
val Green300 = Color(0xFF81C784)
val Green500 = Color(0xFF4CAF50)
val Green700 = Color(0xFF388E3C)
val Green900 = Color(0xFF1B5E20)
val Red300 = Color(0xFFE57373)
val Red500 = Color(0xFFF44336)
val Red700 = Color(0xFFD32F2F)
val Red900 = Color(0xFFB71C1C)
object TwigsColors {
val Green
@Composable
get() = if (isSystemInDarkTheme()) Green300 else Green700
val DarkGreen
@Composable
get() = if (isSystemInDarkTheme()) Green500 else Green900
val Red
@Composable
get() = if (isSystemInDarkTheme()) Red300 else Red700
val DarkRed
@Composable
get() = if (isSystemInDarkTheme()) Red500 else Red900
}
val md_theme_light_primary = Color(0xFF006E26)
val md_theme_light_onPrimary = Color(0xFFFFFFFF)
val md_theme_light_primaryContainer = Color(0xFF6CFF82)
val md_theme_light_onPrimaryContainer = Color(0xFF002106)
val md_theme_light_secondary = Color(0xFF526350)
val md_theme_light_onSecondary = Color(0xFFFFFFFF)
val md_theme_light_secondaryContainer = Color(0xFFD5E8D0)
val md_theme_light_onSecondaryContainer = Color(0xFF101F10)
val md_theme_light_tertiary = Color(0xFF39656B)
val md_theme_light_onTertiary = Color(0xFFFFFFFF)
val md_theme_light_tertiaryContainer = Color(0xFFBCEBF2)
val md_theme_light_onTertiaryContainer = Color(0xFF001F23)
val md_theme_light_error = Color(0xFFBA1A1A)
val md_theme_light_errorContainer = Color(0xFFFFDAD6)
val md_theme_light_onError = Color(0xFFFFFFFF)
val md_theme_light_onErrorContainer = Color(0xFF410002)
val md_theme_light_background = Color(0xFFFCFDF7)
val md_theme_light_onBackground = Color(0xFF1A1C19)
val md_theme_light_surface = Color(0xFFFCFDF7)
val md_theme_light_onSurface = Color(0xFF1A1C19)
val md_theme_light_surfaceVariant = Color(0xFFDEE5D9)
val md_theme_light_onSurfaceVariant = Color(0xFF424940)
val md_theme_light_outline = Color(0xFF72796F)
val md_theme_light_inverseOnSurface = Color(0xFFF0F1EB)
val md_theme_light_inverseSurface = Color(0xFF2F312D)
val md_theme_light_inversePrimary = Color(0xFF47E266)
val md_theme_light_shadow = Color(0xFF000000)
val md_theme_light_surfaceTint = Color(0xFF006E26)
val md_theme_light_outlineVariant = Color(0xFFC2C9BD)
val md_theme_light_scrim = Color(0xFF000000)
val md_theme_dark_primary = Color(0xFF47E266)
val md_theme_dark_onPrimary = Color(0xFF003910)
val md_theme_dark_primaryContainer = Color(0xFF00531A)
val md_theme_dark_onPrimaryContainer = Color(0xFF6CFF82)
val md_theme_dark_secondary = Color(0xFFB9CCB4)
val md_theme_dark_onSecondary = Color(0xFF243424)
val md_theme_dark_secondaryContainer = Color(0xFF3A4B39)
val md_theme_dark_onSecondaryContainer = Color(0xFFD5E8D0)
val md_theme_dark_tertiary = Color(0xFFA1CED5)
val md_theme_dark_onTertiary = Color(0xFF00363C)
val md_theme_dark_tertiaryContainer = Color(0xFF1F4D53)
val md_theme_dark_onTertiaryContainer = Color(0xFFBCEBF2)
val md_theme_dark_error = Color(0xFFFFB4AB)
val md_theme_dark_errorContainer = Color(0xFF93000A)
val md_theme_dark_onError = Color(0xFF690005)
val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6)
val md_theme_dark_background = Color(0xFF1A1C19)
val md_theme_dark_onBackground = Color(0xFFE2E3DD)
val md_theme_dark_surface = Color(0xFF1A1C19)
val md_theme_dark_onSurface = Color(0xFFE2E3DD)
val md_theme_dark_surfaceVariant = Color(0xFF424940)
val md_theme_dark_onSurfaceVariant = Color(0xFFC2C9BD)
val md_theme_dark_outline = Color(0xFF8C9388)
val md_theme_dark_inverseOnSurface = Color(0xFF1A1C19)
val md_theme_dark_inverseSurface = Color(0xFFE2E3DD)
val md_theme_dark_inversePrimary = Color(0xFF006E26)
val md_theme_dark_shadow = Color(0xFF000000)
val md_theme_dark_surfaceTint = Color(0xFF47E266)
val md_theme_dark_outlineVariant = Color(0xFF424940)
val md_theme_dark_scrim = Color(0xFF000000)
val seed = Color(0xFF30D158)

View file

@ -0,0 +1,119 @@
package com.wbrawner.twigs.android.ui.base
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import com.wbrawner.twigs.android.R
private val LightColors = lightColorScheme(
primary = md_theme_light_primary,
onPrimary = md_theme_light_onPrimary,
primaryContainer = md_theme_light_primaryContainer,
onPrimaryContainer = md_theme_light_onPrimaryContainer,
secondary = md_theme_light_secondary,
onSecondary = md_theme_light_onSecondary,
secondaryContainer = md_theme_light_secondaryContainer,
onSecondaryContainer = md_theme_light_onSecondaryContainer,
tertiary = md_theme_light_tertiary,
onTertiary = md_theme_light_onTertiary,
tertiaryContainer = md_theme_light_tertiaryContainer,
onTertiaryContainer = md_theme_light_onTertiaryContainer,
error = md_theme_light_error,
errorContainer = md_theme_light_errorContainer,
onError = md_theme_light_onError,
onErrorContainer = md_theme_light_onErrorContainer,
background = md_theme_light_background,
onBackground = md_theme_light_onBackground,
surface = md_theme_light_surface,
onSurface = md_theme_light_onSurface,
surfaceVariant = md_theme_light_surfaceVariant,
onSurfaceVariant = md_theme_light_onSurfaceVariant,
outline = md_theme_light_outline,
inverseOnSurface = md_theme_light_inverseOnSurface,
inverseSurface = md_theme_light_inverseSurface,
inversePrimary = md_theme_light_inversePrimary,
surfaceTint = md_theme_light_surfaceTint,
outlineVariant = md_theme_light_outlineVariant,
scrim = md_theme_light_scrim,
)
private val DarkColors = darkColorScheme(
primary = md_theme_dark_primary,
onPrimary = md_theme_dark_onPrimary,
primaryContainer = md_theme_dark_primaryContainer,
onPrimaryContainer = md_theme_dark_onPrimaryContainer,
secondary = md_theme_dark_secondary,
onSecondary = md_theme_dark_onSecondary,
secondaryContainer = md_theme_dark_secondaryContainer,
onSecondaryContainer = md_theme_dark_onSecondaryContainer,
tertiary = md_theme_dark_tertiary,
onTertiary = md_theme_dark_onTertiary,
tertiaryContainer = md_theme_dark_tertiaryContainer,
onTertiaryContainer = md_theme_dark_onTertiaryContainer,
error = md_theme_dark_error,
errorContainer = md_theme_dark_errorContainer,
onError = md_theme_dark_onError,
onErrorContainer = md_theme_dark_onErrorContainer,
background = md_theme_dark_background,
onBackground = md_theme_dark_onBackground,
surface = md_theme_dark_surface,
onSurface = md_theme_dark_onSurface,
surfaceVariant = md_theme_dark_surfaceVariant,
onSurfaceVariant = md_theme_dark_onSurfaceVariant,
outline = md_theme_dark_outline,
inverseOnSurface = md_theme_dark_inverseOnSurface,
inverseSurface = md_theme_dark_inverseSurface,
inversePrimary = md_theme_dark_inversePrimary,
surfaceTint = md_theme_dark_surfaceTint,
outlineVariant = md_theme_dark_outlineVariant,
scrim = md_theme_dark_scrim,
)
val ubuntu = FontFamily(
Font(R.font.ubuntu_bold, weight = FontWeight.Bold),
Font(R.font.ubuntu_regular, weight = FontWeight.Normal),
Font(R.font.ubuntu_light, weight = FontWeight.Light),
Font(R.font.ubuntu_bolditalic, weight = FontWeight.Bold, style = FontStyle.Italic),
Font(R.font.ubuntu_italic, weight = FontWeight.Normal, style = FontStyle.Italic),
Font(R.font.ubuntu_lightitalic, weight = FontWeight.Light, style = FontStyle.Italic),
)
@Composable
fun TwigsTheme(darkMode: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) {
MaterialTheme(
colorScheme = if (darkMode) DarkColors else LightColors,
typography = MaterialTheme.typography.copy(
displayLarge = MaterialTheme.typography.displayLarge.copy(fontFamily = ubuntu),
displayMedium = MaterialTheme.typography.displayMedium.copy(fontFamily = ubuntu),
displaySmall = MaterialTheme.typography.displaySmall.copy(fontFamily = ubuntu),
headlineLarge = MaterialTheme.typography.headlineLarge.copy(fontFamily = ubuntu),
headlineMedium = MaterialTheme.typography.headlineMedium.copy(fontFamily = ubuntu),
headlineSmall = MaterialTheme.typography.headlineSmall.copy(fontFamily = ubuntu),
titleLarge = MaterialTheme.typography.titleLarge.copy(fontFamily = ubuntu),
titleMedium = MaterialTheme.typography.titleMedium.copy(fontFamily = ubuntu),
titleSmall = MaterialTheme.typography.titleSmall.copy(fontFamily = ubuntu),
bodyLarge = MaterialTheme.typography.bodyLarge.copy(fontFamily = ubuntu),
bodyMedium = MaterialTheme.typography.bodyMedium.copy(fontFamily = ubuntu),
bodySmall = MaterialTheme.typography.bodySmall.copy(fontFamily = ubuntu),
labelLarge = MaterialTheme.typography.labelLarge.copy(fontFamily = ubuntu),
labelMedium = MaterialTheme.typography.labelMedium.copy(fontFamily = ubuntu),
labelSmall = MaterialTheme.typography.labelSmall.copy(fontFamily = ubuntu),
),
content = content
)
}
@Composable
fun TwigsApp(darkMode: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) {
TwigsTheme(darkMode = darkMode) {
Surface(content = content)
}
}

View file

@ -0,0 +1,152 @@
package com.wbrawner.twigs.android.ui.category
import android.content.res.Configuration.UI_MODE_NIGHT_NO
import android.content.res.Configuration.UI_MODE_NIGHT_YES
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.wbrawner.twigs.android.ui.TwigsScaffold
import com.wbrawner.twigs.android.ui.base.TwigsApp
import com.wbrawner.twigs.android.ui.transaction.toCurrencyString
import com.wbrawner.twigs.shared.Store
import com.wbrawner.twigs.shared.category.Category
import com.wbrawner.twigs.shared.category.CategoryAction
import com.wbrawner.twigs.shared.category.groupByType
import com.wbrawner.twigs.shared.recurringtransaction.capitalizedName
import kotlin.math.abs
import kotlin.math.max
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CategoriesScreen(store: Store) {
val state by store.state.collectAsState()
val budget = state.selectedBudget?.let { id -> state.budgets?.first { it.id == id } }
TwigsScaffold(
store = store,
title = budget?.name ?: "Select a Budget",
onClickFab = {
store.dispatch(CategoryAction.NewCategoryClicked)
}
) {
state.categories?.let { categories ->
val categoryGroups = categories.groupByType()
Column(
modifier = Modifier
.fillMaxWidth()
.padding(it)
.verticalScroll(rememberScrollState())
.padding(start = 8.dp, end = 8.dp, bottom = 8.dp)
) {
categoryGroups.toSortedMap().forEach { (group, c) ->
Text(
modifier = Modifier.padding(8.dp),
text = group.capitalizedName,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold
)
Card {
c.forEach { category ->
CategoryListItem(category, state.categoryBalances?.get(category.id!!)) {
store.dispatch(CategoryAction.SelectCategory(category.id))
}
}
}
}
}
} ?: Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
if (state.editingCategory) {
CategoryFormDialog(store = store)
}
}
}
@Composable
fun CategoryListItem(category: Category, balance: Long?, onClick: (Category) -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onClick(category) }
.padding(8.dp)
.heightIn(min = 56.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier.weight(1f),
verticalArrangement = spacedBy(4.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(category.title, style = MaterialTheme.typography.bodyLarge)
balance?.let {
Text(
(category.amount - abs(it)).toCurrencyString() + " remaining",
style = MaterialTheme.typography.bodySmall
)
}
}
Spacer(modifier = Modifier.height(8.dp))
balance?.let {
val denominator = remember { max(abs(it), abs(category.amount)).toFloat() }
LinearProgressIndicator(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(4.dp)),
progress = { if (denominator == 0f) 0f else abs(it).toFloat() / denominator },
color = if (category.expense) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
trackColor = Color.LightGray
)
} ?: LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
}
}
@Composable
@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_NO)
@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_YES)
fun CategoryListItem_Preview() {
TwigsApp {
CategoryListItem(
category = Category(
title = "Groceries",
amount = 150000,
budgetId = "budgetId",
expense = true,
),
balance = null
) {}
}
}

View file

@ -0,0 +1,177 @@
package com.wbrawner.twigs.android.ui.category
import android.content.res.Configuration.UI_MODE_NIGHT_NO
import android.content.res.Configuration.UI_MODE_NIGHT_YES
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.wbrawner.twigs.android.ui.TwigsScaffold
import com.wbrawner.twigs.android.ui.base.TwigsApp
import com.wbrawner.twigs.android.ui.transaction.TransactionFormDialog
import com.wbrawner.twigs.android.ui.transaction.TransactionListItem
import com.wbrawner.twigs.android.ui.transaction.toCurrencyString
import com.wbrawner.twigs.android.ui.util.format
import com.wbrawner.twigs.shared.Action
import com.wbrawner.twigs.shared.Store
import com.wbrawner.twigs.shared.category.Category
import com.wbrawner.twigs.shared.category.CategoryAction
import com.wbrawner.twigs.shared.transaction.Transaction
import com.wbrawner.twigs.shared.transaction.TransactionAction
import com.wbrawner.twigs.shared.transaction.groupByDate
import kotlinx.datetime.Clock
import kotlinx.datetime.toInstant
import kotlin.math.abs
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CategoryDetailsScreen(store: Store) {
val state by store.state.collectAsState()
val category =
remember(state.editingCategory) { state.categories!!.first { it.id == state.selectedCategory } }
TwigsScaffold(
store = store,
title = category.title,
navigationIcon = {
IconButton(onClick = { store.dispatch(Action.Back) }) {
Icon(Icons.Default.ArrowBack, "Go back")
}
},
actions = {
IconButton({ store.dispatch(CategoryAction.EditCategory(requireNotNull(category.id))) }) {
Icon(Icons.Default.Edit, "Edit")
}
},
onClickFab = {
store.dispatch(TransactionAction.NewTransactionClicked)
}
) { padding ->
CategoryDetails(
modifier = Modifier.padding(padding),
category = category,
balance = state.categoryBalances!![category.id!!] ?: 0,
transactions = state.transactions!!.filter { it.categoryId == category.id },
onTransactionClicked = { store.dispatch(TransactionAction.SelectTransaction(it.id)) }
)
if (state.editingTransaction) {
TransactionFormDialog(store = store)
}
if (state.editingCategory) {
CategoryFormDialog(store = store)
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun CategoryDetails(
modifier: Modifier = Modifier,
category: Category,
balance: Long,
transactions: List<Transaction>,
onTransactionClicked: (Transaction) -> Unit
) {
val transactionGroups = remember { transactions.groupByDate() }
LazyColumn(
modifier = modifier
.fillMaxSize()
.padding(horizontal = 8.dp)
) {
category.description?.let {
item {
Text(modifier = Modifier.padding(8.dp), text = it)
}
}
item {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
LabeledCounter("Planned", category.amount.toCurrencyString())
LabeledCounter("Actual", abs(balance).toCurrencyString())
LabeledCounter("Remaining", (category.amount - abs(balance)).toCurrencyString())
}
}
transactionGroups.forEach { (timestamp, transactions) ->
item(timestamp) {
Text(
modifier = Modifier.padding(8.dp),
text = timestamp.toInstant().format(LocalContext.current),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
}
itemsIndexed(transactions) { index, transaction ->
TransactionListItem(
modifier = Modifier.animateItemPlacement(),
transaction = transaction,
isFirst = index == 0,
isLast = index == transactions.lastIndex,
onClick = onTransactionClicked
)
}
}
}
}
@Composable
fun LabeledCounter(label: String, counter: String) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = spacedBy(4.dp)
) {
Text(text = label, style = MaterialTheme.typography.labelMedium)
Text(text = counter, style = MaterialTheme.typography.bodyLarge)
}
}
@Composable
@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_NO)
@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_YES)
fun CategoryDetails_Preview() {
TwigsApp {
CategoryDetails(
category = Category(title = "Coffee", budgetId = "budget", amount = 1000),
balance = 500,
transactions = listOf(
Transaction(
title = "DAZBOG",
description = "Chokolat Cappuccino",
date = Clock.System.now(),
amount = 550,
categoryId = "coffee",
budgetId = "budget",
createdBy = "user",
expense = true
)
),
onTransactionClicked = {}
)
}
}

View file

@ -0,0 +1,292 @@
package com.wbrawner.twigs.android.ui.category
import android.content.res.Configuration.UI_MODE_NIGHT_NO
import android.content.res.Configuration.UI_MODE_NIGHT_YES
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.isShiftPressed
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.wbrawner.twigs.android.ui.base.TwigsApp
import com.wbrawner.twigs.android.ui.transaction.toDecimalString
import com.wbrawner.twigs.shared.Store
import com.wbrawner.twigs.shared.category.Category
import com.wbrawner.twigs.shared.category.CategoryAction
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun CategoryFormDialog(store: Store) {
Dialog(
onDismissRequest = { store.dispatch(CategoryAction.CancelEditCategory) },
properties = DialogProperties(
usePlatformDefaultWidth = false,
decorFitsSystemWindows = false
)
) {
CategoryForm(store)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CategoryForm(store: Store) {
val state by store.state.collectAsState()
val category = remember {
val defaultCategory = Category(
title = "",
amount = 0L,
expense = true,
budgetId = state.selectedBudget!!,
)
if (state.selectedCategory.isNullOrBlank()) {
defaultCategory
} else {
state.categories?.first { it.id == state.selectedCategory } ?: defaultCategory
}
}
val (title, setTitle) = remember { mutableStateOf(category.title) }
val (description, setDescription) = remember { mutableStateOf(category.description ?: "") }
val (amount, setAmount) = remember { mutableStateOf(category.amount.toDecimalString()) }
val (expense, setExpense) = remember { mutableStateOf(category.expense) }
val (archived, setArchived) = remember { mutableStateOf(category.archived) }
Scaffold(
topBar = {
TopAppBar(
navigationIcon = {
IconButton(onClick = { store.dispatch(CategoryAction.CancelEditCategory) }) {
Icon(Icons.Default.Close, "Cancel")
}
},
title = {
Text(if (category.id.isNullOrBlank()) "New Category" else "Edit Category")
}
)
}
) {
CategoryForm(
modifier = Modifier.padding(it),
title = title,
setTitle = setTitle,
description = description,
setDescription = setDescription,
amount = amount,
setAmount = setAmount,
expense = expense,
setExpense = setExpense,
archived = archived,
setArchived = setArchived
) {
store.dispatch(
category.id?.let { id ->
CategoryAction.UpdateCategory(
id = id,
title = title,
description = description,
amount = (amount.toDouble() * 100).toLong(),
expense = expense,
archived = archived
)
} ?: CategoryAction.CreateCategory(
title = title,
description = description,
amount = (amount.toDouble() * 100).toLong(),
expense = expense,
archived = archived
)
)
}
}
}
@OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class)
@Composable
fun CategoryForm(
modifier: Modifier,
title: String,
setTitle: (String) -> Unit,
description: String,
setDescription: (String) -> Unit,
amount: String,
setAmount: (String) -> Unit,
expense: Boolean,
setExpense: (Boolean) -> Unit,
archived: Boolean,
setArchived: (Boolean) -> Unit,
save: () -> Unit
) {
val scrollState = rememberScrollState()
val (titleInput, descriptionInput, amountInput) = FocusRequester.createRefs()
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(scrollState)
.padding(16.dp),
verticalArrangement = spacedBy(8.dp, Alignment.Top),
horizontalAlignment = Alignment.CenterHorizontally,
) {
// if (error.isNotBlank()) {
// Text(text = error, color = Color.Red)
// }
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.focusRequester(titleInput)
.onPreviewKeyEvent {
if (it.key == Key.Tab && !it.isShiftPressed) {
descriptionInput.requestFocus()
true
} else {
false
}
},
value = title,
onValueChange = setTitle,
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Text,
capitalization = KeyboardCapitalization.Words,
imeAction = ImeAction.Next
),
label = { Text("Title") },
keyboardActions = KeyboardActions(onNext = {
descriptionInput.requestFocus()
}),
maxLines = 1
)
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.focusRequester(descriptionInput)
.onPreviewKeyEvent {
if (it.key == Key.Tab) {
if (it.isShiftPressed) {
titleInput.requestFocus()
} else {
amountInput.requestFocus()
}
true
} else {
false
}
},
value = description,
onValueChange = setDescription,
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Text,
capitalization = KeyboardCapitalization.Sentences,
imeAction = ImeAction.Next
),
label = { Text("Description") },
keyboardActions = KeyboardActions(onNext = {
amountInput.requestFocus()
}),
)
val keyboardController = LocalSoftwareKeyboardController.current
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.focusRequester(amountInput)
.onPreviewKeyEvent {
if (it.key == Key.Tab && it.isShiftPressed) {
descriptionInput.requestFocus()
true
} else {
false
}
},
value = amount,
onValueChange = setAmount,
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Decimal,
imeAction = ImeAction.Next
),
label = { Text("Amount") },
keyboardActions = KeyboardActions(onNext = {
keyboardController?.hide()
}),
)
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = spacedBy(8.dp)) {
Row(
modifier = Modifier.clickable {
setExpense(true)
},
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(selected = expense, onClick = { setExpense(true) })
Text(text = "Expense")
}
Row(
modifier = Modifier.clickable {
setExpense(false)
},
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(selected = !expense, onClick = { setExpense(false) })
Text(text = "Income")
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { setArchived(!archived) },
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(checked = archived, onCheckedChange = { setArchived(!archived) })
Text("Archived")
}
Button(
modifier = Modifier.fillMaxWidth(),
onClick = save
) {
Text("Save")
}
}
}
@Composable
@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_NO)
@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_YES)
fun CategoryForm_Preview() {
TwigsApp {
CategoryForm(store = Store(reducers = emptyList()))
}
}

View file

@ -0,0 +1,167 @@
package com.wbrawner.twigs.android.ui.recurringtransaction
import android.content.res.Configuration.UI_MODE_NIGHT_NO
import android.content.res.Configuration.UI_MODE_NIGHT_YES
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.scrollable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.wbrawner.twigs.android.ui.TwigsScaffold
import com.wbrawner.twigs.android.ui.base.TwigsApp
import com.wbrawner.twigs.android.ui.transaction.toCurrencyString
import com.wbrawner.twigs.android.ui.util.format
import com.wbrawner.twigs.shared.Action
import com.wbrawner.twigs.shared.Store
import com.wbrawner.twigs.shared.budget.Budget
import com.wbrawner.twigs.shared.category.Category
import com.wbrawner.twigs.shared.recurringtransaction.Frequency
import com.wbrawner.twigs.shared.recurringtransaction.RecurringTransaction
import com.wbrawner.twigs.shared.recurringtransaction.RecurringTransactionAction
import com.wbrawner.twigs.shared.recurringtransaction.Time
import com.wbrawner.twigs.shared.user.User
import kotlinx.datetime.Clock
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RecurringTransactionDetailsScreen(store: Store) {
val state by store.state.collectAsState()
val transaction =
remember { state.recurringTransactions!!.first { it.id == state.selectedRecurringTransaction } }
val createdBy = state.selectedRecurringTransactionCreatedBy ?: run {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
return
}
val category = state.categories?.firstOrNull { it.id == transaction.categoryId }
val budget = state.budgets!!.first { it.id == transaction.budgetId }
TwigsScaffold(
store = store,
title = "Transaction Details",
navigationIcon = {
IconButton(onClick = { store.dispatch(Action.Back) }) {
Icon(Icons.Default.ArrowBack, "Go back")
}
},
actions = {
IconButton({
store.dispatch(
RecurringTransactionAction.EditRecurringTransaction(
requireNotNull(transaction.id)
)
)
}) {
Icon(Icons.Default.Edit, "Edit")
}
}
) { padding ->
RecurringTransactionDetails(
modifier = Modifier.padding(padding),
transaction = transaction,
category = category,
budget = budget,
createdBy = createdBy
)
if (state.editingRecurringTransaction) {
RecurringTransactionFormDialog(store = store)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RecurringTransactionDetails(
modifier: Modifier = Modifier,
transaction: RecurringTransaction,
category: Category? = null,
budget: Budget,
createdBy: User
) {
val scrollState = rememberScrollState()
Column(
modifier = modifier
.fillMaxSize()
.scrollable(scrollState, Orientation.Vertical)
.padding(16.dp),
verticalArrangement = spacedBy(16.dp)
) {
Text(
text = transaction.title,
style = MaterialTheme.typography.headlineMedium
)
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(
text = transaction.amount.toCurrencyString(),
style = MaterialTheme.typography.headlineSmall,
color = if (transaction.expense) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
)
}
LabeledField("Description", transaction.description ?: "")
LabeledField("Frequency", transaction.frequency.description)
LabeledField("Time", transaction.frequency.time.toString())
LabeledField("Start", transaction.start.format(LocalContext.current))
transaction.finish?.let {
LabeledField("End", it.format(LocalContext.current))
}
LabeledField("Category", category?.title ?: "")
LabeledField("Created By", createdBy.username)
}
}
@Composable
fun LabeledField(label: String, field: String) {
Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = spacedBy(4.dp)) {
Text(text = label, style = MaterialTheme.typography.bodySmall)
Text(text = field, style = MaterialTheme.typography.bodyLarge)
}
}
@Composable
@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_NO)
@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_YES)
fun TransactionDetails_Preview() {
TwigsApp {
RecurringTransactionDetails(
transaction = RecurringTransaction(
title = "DAZBOG",
description = "Chokolat Cappuccino",
frequency = Frequency.Daily(1, Time(9, 0, 0)),
start = Clock.System.now(),
amount = 550,
categoryId = "coffee",
budgetId = "budget",
createdBy = "user",
expense = true
),
category = Category(title = "Coffee", budgetId = "budget", amount = 1000),
budget = Budget(name = "Monthly Budget"),
createdBy = User(username = "user")
)
}
}

View file

@ -0,0 +1,419 @@
package com.wbrawner.twigs.android.ui.recurringtransaction
import android.content.res.Configuration.UI_MODE_NIGHT_NO
import android.content.res.Configuration.UI_MODE_NIGHT_YES
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.isShiftPressed
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.wbrawner.twigs.android.ui.base.TwigsApp
import com.wbrawner.twigs.android.ui.transaction.toDecimalString
import com.wbrawner.twigs.android.ui.util.DatePicker
import com.wbrawner.twigs.android.ui.util.FrequencyPicker
import com.wbrawner.twigs.shared.Store
import com.wbrawner.twigs.shared.category.Category
import com.wbrawner.twigs.shared.recurringtransaction.Frequency
import com.wbrawner.twigs.shared.recurringtransaction.RecurringTransaction
import com.wbrawner.twigs.shared.recurringtransaction.RecurringTransactionAction
import com.wbrawner.twigs.shared.recurringtransaction.Time
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun RecurringTransactionFormDialog(store: Store) {
Dialog(
onDismissRequest = { store.dispatch(RecurringTransactionAction.CancelEditRecurringTransaction) },
properties = DialogProperties(
usePlatformDefaultWidth = false,
decorFitsSystemWindows = false
)
) {
RecurringTransactionForm(store)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RecurringTransactionForm(store: Store) {
val state by store.state.collectAsState()
val transaction = remember {
val defaultTransaction = RecurringTransaction(
title = "",
start = Clock.System.now(),
amount = 0L,
frequency = Frequency.Daily(1, Time(9, 0, 0)),
budgetId = state.selectedBudget!!,
categoryId = state.selectedCategory,
expense = true,
createdBy = state.user!!.id!!
)
if (state.selectedRecurringTransaction.isNullOrBlank()) {
defaultTransaction
} else {
state.recurringTransactions?.first { it.id == state.selectedRecurringTransaction }
?: defaultTransaction
}
}
val (title, setTitle) = remember { mutableStateOf(transaction.title) }
val (description, setDescription) = remember { mutableStateOf(transaction.description ?: "") }
val (frequency, setFrequency) = remember { mutableStateOf(transaction.frequency) }
val (start, setStart) = remember { mutableStateOf(transaction.start) }
val (end, setEnd) = remember { mutableStateOf(transaction.finish) }
val (amount, setAmount) = remember { mutableStateOf(transaction.amount.toDecimalString()) }
val (expense, setExpense) = remember { mutableStateOf(transaction.expense) }
val budget = remember { state.budgets!!.first { it.id == transaction.budgetId } }
val (category, setCategory) = remember { mutableStateOf(transaction.categoryId?.let { categoryId -> state.categories?.firstOrNull { it.id == categoryId } }) }
Scaffold(
topBar = {
TopAppBar(
navigationIcon = {
IconButton(onClick = { store.dispatch(RecurringTransactionAction.CancelEditRecurringTransaction) }) {
Icon(Icons.Default.Close, "Cancel")
}
},
title = {
Text(if (transaction.id.isNullOrBlank()) "New Transaction" else "Edit Transaction")
}
)
}
) {
RecurringTransactionForm(
modifier = Modifier.padding(it),
title = title,
setTitle = setTitle,
description = description,
setDescription = setDescription,
frequency = frequency,
setFrequency = setFrequency,
start = start,
setStart = setStart,
end = end,
setEnd = setEnd,
amount = amount,
setAmount = setAmount,
expense = expense,
setExpense = setExpense,
categories = state.categories?.filter { c -> c.expense == expense && !c.archived }
?: emptyList(),
category = category,
setCategory = setCategory
) {
store.dispatch(
transaction.id?.let { id ->
RecurringTransactionAction.UpdateRecurringTransaction(
id = id,
title = title,
amount = (amount.toDouble() * 100).toLong(),
frequency = frequency,
start = start,
end = end,
expense = expense,
category = category,
budget = budget
)
} ?: RecurringTransactionAction.CreateRecurringTransaction(
title = title,
description = description,
amount = (amount.toDouble() * 100).toLong(),
frequency = frequency,
start = start,
end = end,
expense = expense,
category = category,
budget = budget
)
)
}
}
}
@OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class)
@Composable
fun RecurringTransactionForm(
modifier: Modifier,
title: String,
setTitle: (String) -> Unit,
description: String,
setDescription: (String) -> Unit,
frequency: Frequency,
setFrequency: (Frequency) -> Unit,
start: Instant,
setStart: (Instant) -> Unit,
end: Instant?,
setEnd: (Instant?) -> Unit,
amount: String,
setAmount: (String) -> Unit,
expense: Boolean,
setExpense: (Boolean) -> Unit,
categories: List<Category>,
category: Category?,
setCategory: (Category?) -> Unit,
save: () -> Unit
) {
val scrollState = rememberScrollState()
val (titleInput, descriptionInput, amountInput) = FocusRequester.createRefs()
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(scrollState)
.padding(16.dp),
verticalArrangement = spacedBy(8.dp, Alignment.Top),
horizontalAlignment = Alignment.CenterHorizontally,
) {
// if (error.isNotBlank()) {
// Text(text = error, color = Color.Red)
// }
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.focusRequester(titleInput)
.onPreviewKeyEvent {
if (it.key == Key.Tab && !it.isShiftPressed) {
descriptionInput.requestFocus()
true
} else {
false
}
},
value = title,
onValueChange = setTitle,
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Text,
capitalization = KeyboardCapitalization.Words,
imeAction = ImeAction.Next
),
label = { Text("Title") },
keyboardActions = KeyboardActions(onNext = {
descriptionInput.requestFocus()
}),
maxLines = 1
)
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.focusRequester(descriptionInput)
.onPreviewKeyEvent {
if (it.key == Key.Tab) {
if (it.isShiftPressed) {
titleInput.requestFocus()
} else {
amountInput.requestFocus()
}
true
} else {
false
}
},
value = description,
onValueChange = setDescription,
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Text,
capitalization = KeyboardCapitalization.Sentences,
imeAction = ImeAction.Next
),
label = { Text("Description") },
keyboardActions = KeyboardActions(onNext = {
amountInput.requestFocus()
}),
)
val keyboardController = LocalSoftwareKeyboardController.current
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.focusRequester(amountInput)
.onPreviewKeyEvent {
if (it.key == Key.Tab && it.isShiftPressed) {
descriptionInput.requestFocus()
true
} else {
false
}
},
value = amount,
onValueChange = setAmount,
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Decimal,
imeAction = ImeAction.Next
),
label = { Text("Amount") },
keyboardActions = KeyboardActions(onNext = {
keyboardController?.hide()
}),
)
FrequencyPicker(frequency, setFrequency)
val (startPickerVisible, setStartPickerVisible) = remember { mutableStateOf(false) }
DatePicker(
modifier = Modifier.fillMaxWidth(),
date = start,
setDate = setStart,
label = "Start Date",
dialogVisible = startPickerVisible,
setDialogVisible = setStartPickerVisible
)
val (endExpanded, setEndExpanded) = remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
modifier = Modifier
.fillMaxWidth(),
expanded = endExpanded,
onExpandedChange = setEndExpanded,
) {
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
value = end?.let { "On Date" } ?: "Never",
onValueChange = {},
readOnly = true,
label = {
Text("End Criteria")
},
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = endExpanded)
}
)
ExposedDropdownMenu(expanded = endExpanded, onDismissRequest = {
setEndExpanded(false)
}) {
DropdownMenuItem(
text = { Text("Never") },
onClick = {
setEnd(null)
setEndExpanded(false)
}
)
DropdownMenuItem(
text = { Text("On Date") },
onClick = {
setEnd(Clock.System.now())
setEndExpanded(false)
}
)
}
}
end?.let {
val (endPickerVisible, setEndPickerVisible) = remember { mutableStateOf(false) }
DatePicker(
modifier = Modifier.fillMaxWidth(),
date = end,
setDate = setEnd,
label = "End Date",
dialogVisible = endPickerVisible,
setDialogVisible = setEndPickerVisible
)
}
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = spacedBy(8.dp)) {
Row(
modifier = Modifier.clickable {
setExpense(true)
},
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(selected = expense, onClick = { setExpense(true) })
Text(text = "Expense")
}
Row(
modifier = Modifier.clickable {
setExpense(false)
},
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(selected = !expense, onClick = { setExpense(false) })
Text(text = "Income")
}
}
val (categoriesExpanded, setCategoriesExpanded) = remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
modifier = Modifier
.fillMaxWidth(),
expanded = categoriesExpanded,
onExpandedChange = setCategoriesExpanded,
) {
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
value = category?.title ?: "",
onValueChange = {},
readOnly = true,
label = {
Text("Category")
},
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = categoriesExpanded)
}
)
ExposedDropdownMenu(expanded = categoriesExpanded, onDismissRequest = {
setCategoriesExpanded(false)
}) {
categories.forEach { c ->
DropdownMenuItem(
text = { Text(c.title) },
onClick = {
setCategory(c)
setCategoriesExpanded(false)
}
)
}
}
}
Button(
modifier = Modifier.fillMaxWidth(),
onClick = save
) {
Text("Save")
}
}
}
@Composable
@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_NO)
@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_YES)
fun RecurringTransactionForm_Preview() {
TwigsApp {
RecurringTransactionForm(store = Store(reducers = emptyList()))
}
}

View file

@ -0,0 +1,142 @@
package com.wbrawner.twigs.android.ui.recurringtransaction
import android.content.res.Configuration.UI_MODE_NIGHT_NO
import android.content.res.Configuration.UI_MODE_NIGHT_YES
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.wbrawner.twigs.android.ui.TwigsScaffold
import com.wbrawner.twigs.android.ui.base.TwigsApp
import com.wbrawner.twigs.android.ui.transaction.toCurrencyString
import com.wbrawner.twigs.shared.Store
import com.wbrawner.twigs.shared.recurringtransaction.Frequency
import com.wbrawner.twigs.shared.recurringtransaction.RecurringTransaction
import com.wbrawner.twigs.shared.recurringtransaction.RecurringTransactionAction
import com.wbrawner.twigs.shared.recurringtransaction.groupByStatus
import kotlinx.datetime.Clock
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RecurringTransactionsScreen(store: Store) {
val state by store.state.collectAsState()
val budget = state.selectedBudget?.let { id -> state.budgets?.first { it.id == id } }
TwigsScaffold(
store = store,
title = budget?.name ?: "Select a Budget",
onClickFab = {
store.dispatch(RecurringTransactionAction.NewRecurringTransactionClicked)
}
) {
state.recurringTransactions?.let { transactions ->
val transactionGroups =
remember(state.editingRecurringTransaction) { transactions.groupByStatus() }
Column(
modifier = Modifier
.fillMaxSize()
.padding(it)
.verticalScroll(rememberScrollState())
.padding(start = 8.dp, end = 8.dp, bottom = 8.dp)
) {
transactionGroups.forEach { (title, transactions) ->
Text(
modifier = Modifier.padding(8.dp),
text = title,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold
)
Card {
transactions.forEach { transaction ->
RecurringTransactionListItem(transaction) {
store.dispatch(
RecurringTransactionAction.SelectRecurringTransaction(
transaction.id
)
)
}
}
}
}
}
} ?: Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
if (state.editingRecurringTransaction) {
RecurringTransactionFormDialog(store = store)
}
}
}
@Composable
fun RecurringTransactionListItem(
transaction: RecurringTransaction,
onClick: (RecurringTransaction) -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onClick(transaction) }
.padding(8.dp)
.heightIn(min = 56.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier.weight(1f),
verticalArrangement = spacedBy(4.dp)
) {
Text(transaction.title, style = MaterialTheme.typography.bodyLarge)
if (!transaction.description.isNullOrBlank()) {
Text(
transaction.description!!,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Text(
transaction.amount.toCurrencyString(),
color = if (transaction.expense) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
)
}
}
@Composable
@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_NO)
@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_YES)
fun RecurringTransactionListItem_Preview() {
TwigsApp {
RecurringTransactionListItem(
transaction = RecurringTransaction(
title = "Google Store",
description = "Pixel 7 Pro",
frequency = Frequency.parse("Y;1;12-31;12:00:00"),
start = Clock.System.now(),
amount = 129999,
budgetId = "budgetId",
expense = true,
createdBy = "createdBy"
)
) {}
}
}

View file

@ -0,0 +1,195 @@
package com.wbrawner.twigs.android.ui.transaction
import android.content.res.Configuration.UI_MODE_NIGHT_NO
import android.content.res.Configuration.UI_MODE_NIGHT_YES
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.scrollable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.wbrawner.twigs.android.ui.TwigsScaffold
import com.wbrawner.twigs.android.ui.base.TwigsApp
import com.wbrawner.twigs.android.ui.util.formatWithTime
import com.wbrawner.twigs.shared.Action
import com.wbrawner.twigs.shared.Store
import com.wbrawner.twigs.shared.budget.Budget
import com.wbrawner.twigs.shared.category.Category
import com.wbrawner.twigs.shared.transaction.Transaction
import com.wbrawner.twigs.shared.transaction.TransactionAction
import com.wbrawner.twigs.shared.user.User
import kotlinx.datetime.Clock
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TransactionDetailsScreen(store: Store) {
val state by store.state.collectAsState()
val transaction =
remember(state.editingTransaction) { state.transactions!!.first { it.id == state.selectedTransaction } }
val createdBy = state.selectedTransactionCreatedBy ?: run {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
return
}
val category = state.categories?.firstOrNull { it.id == transaction.categoryId }
val budget = state.budgets!!.first { it.id == transaction.budgetId }
val (confirmDeletionShown, setConfirmDeletionShown) = remember { mutableStateOf(false) }
TwigsScaffold(
store = store,
title = "Transaction Details",
navigationIcon = {
IconButton(onClick = { store.dispatch(Action.Back) }) {
Icon(Icons.Default.ArrowBack, "Go back")
}
},
actions = {
IconButton({ store.dispatch(TransactionAction.EditTransaction(requireNotNull(transaction.id))) }) {
Icon(Icons.Default.Edit, "Edit")
}
IconButton({ setConfirmDeletionShown(true) }) {
Icon(Icons.Default.Delete, "Delete")
}
}
) { padding ->
TransactionDetails(
modifier = Modifier.padding(padding),
transaction = transaction,
category = category,
budget = budget,
createdBy = createdBy
)
if (state.editingTransaction) {
TransactionFormDialog(store = store)
}
if (confirmDeletionShown) {
AlertDialog(
text = {
Text("Are you sure you want to delete this transaction?")
},
onDismissRequest = { setConfirmDeletionShown(false) },
confirmButton = {
TextButton(onClick = {
setConfirmDeletionShown(false)
store.dispatch(
TransactionAction.DeleteTransaction(
requireNotNull(
transaction.id
)
)
)
}) {
Text("Delete")
}
},
dismissButton = {
TextButton(onClick = {
setConfirmDeletionShown(false)
}) {
Text("Cancel")
}
}
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TransactionDetails(
modifier: Modifier = Modifier,
transaction: Transaction,
category: Category? = null,
budget: Budget,
createdBy: User
) {
val scrollState = rememberScrollState()
Column(
modifier = modifier
.fillMaxSize()
.scrollable(scrollState, Orientation.Vertical)
.padding(16.dp),
verticalArrangement = spacedBy(16.dp)
) {
Text(
text = transaction.title,
style = MaterialTheme.typography.headlineMedium
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = transaction.date.formatWithTime(),
style = MaterialTheme.typography.bodyMedium
)
Text(
text = transaction.amount.toCurrencyString(),
style = MaterialTheme.typography.headlineSmall,
color = if (transaction.expense) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary
)
}
LabeledField("Description", transaction.description ?: "")
LabeledField("Category", category?.title ?: "")
LabeledField("Created By", createdBy.username)
}
}
@Composable
fun LabeledField(label: String, field: String) {
Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = spacedBy(4.dp)) {
Text(text = label, style = MaterialTheme.typography.bodySmall)
Text(text = field, style = MaterialTheme.typography.bodyLarge)
}
}
@Composable
@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_NO)
@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_YES)
fun TransactionDetails_Preview() {
TwigsApp {
TransactionDetails(
transaction = Transaction(
title = "DAZBOG",
description = "Chokolat Cappuccino",
date = Clock.System.now(),
amount = 550,
categoryId = "coffee",
budgetId = "budget",
createdBy = "user",
expense = true
),
category = Category(title = "Coffee", budgetId = "budget", amount = 1000),
budget = Budget(name = "Monthly Budget"),
createdBy = User(username = "user")
)
}
}

View file

@ -0,0 +1,360 @@
package com.wbrawner.twigs.android.ui.transaction
import android.content.res.Configuration.UI_MODE_NIGHT_NO
import android.content.res.Configuration.UI_MODE_NIGHT_YES
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.isShiftPressed
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.wbrawner.twigs.android.ui.base.TwigsApp
import com.wbrawner.twigs.android.ui.util.DatePicker
import com.wbrawner.twigs.android.ui.util.TimePicker
import com.wbrawner.twigs.shared.Store
import com.wbrawner.twigs.shared.category.Category
import com.wbrawner.twigs.shared.transaction.Transaction
import com.wbrawner.twigs.shared.transaction.TransactionAction
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun TransactionFormDialog(store: Store) {
Dialog(
onDismissRequest = { store.dispatch(TransactionAction.CancelEditTransaction) },
properties = DialogProperties(
usePlatformDefaultWidth = false,
decorFitsSystemWindows = false
)
) {
TransactionForm(store)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TransactionForm(store: Store) {
val state by store.state.collectAsState()
val transaction = remember {
val defaultTransaction = Transaction(
title = "",
date = Clock.System.now(),
amount = 0L,
budgetId = state.selectedBudget!!,
categoryId = state.selectedCategory,
expense = true,
createdBy = state.user!!.id!!
)
if (state.selectedTransaction.isNullOrBlank()) {
defaultTransaction
} else {
state.transactions?.first { it.id == state.selectedTransaction } ?: defaultTransaction
}
}
val (title, setTitle) = remember(state.editingTransaction) { mutableStateOf(transaction.title) }
val (description, setDescription) = remember(state.editingTransaction) {
mutableStateOf(
transaction.description ?: ""
)
}
val (date, setDate) = remember(state.editingTransaction) { mutableStateOf(transaction.date) }
val (amount, setAmount) = remember(state.editingTransaction) { mutableStateOf(transaction.amount.toDecimalString()) }
val (expense, setExpense) = remember(state.editingTransaction) { mutableStateOf(transaction.expense) }
val budget =
remember(state.editingTransaction) { state.budgets!!.first { it.id == transaction.budgetId } }
val (category, setCategory) = remember(state.editingTransaction) { mutableStateOf(transaction.categoryId?.let { categoryId -> state.categories?.firstOrNull { it.id == categoryId } }) }
Scaffold(
topBar = {
TopAppBar(
navigationIcon = {
IconButton(onClick = { store.dispatch(TransactionAction.CancelEditTransaction) }) {
Icon(Icons.Default.Close, "Cancel")
}
},
title = {
Text(if (transaction.id.isNullOrBlank()) "New Transaction" else "Edit Transaction")
}
)
}
) {
TransactionForm(
modifier = Modifier.padding(it),
title = title,
setTitle = setTitle,
description = description,
setDescription = setDescription,
date = date,
setDate = setDate,
amount = amount,
setAmount = setAmount,
expense = expense,
setExpense = setExpense,
categories = state.categories?.filter { c -> c.expense == expense && !c.archived }
?: emptyList(),
category = category,
setCategory = setCategory
) {
store.dispatch(
transaction.id?.let { id ->
TransactionAction.UpdateTransaction(
id = id,
title = title,
amount = (amount.toDouble() * 100).toLong(),
date = date,
expense = expense,
category = category,
budget = budget
)
} ?: TransactionAction.CreateTransaction(
title = title,
description = description,
amount = (amount.toDouble() * 100).toLong(),
date = date,
expense = expense,
category = category,
budget = budget
)
)
}
}
}
@OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class)
@Composable
fun TransactionForm(
modifier: Modifier,
title: String,
setTitle: (String) -> Unit,
description: String,
setDescription: (String) -> Unit,
date: Instant,
setDate: (Instant) -> Unit,
amount: String,
setAmount: (String) -> Unit,
expense: Boolean,
setExpense: (Boolean) -> Unit,
categories: List<Category>,
category: Category?,
setCategory: (Category?) -> Unit,
save: () -> Unit
) {
val scrollState = rememberScrollState()
val (titleInput, descriptionInput, amountInput, dateInput) = FocusRequester.createRefs()
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(scrollState)
.padding(16.dp),
verticalArrangement = spacedBy(8.dp, Alignment.Top),
horizontalAlignment = Alignment.CenterHorizontally,
) {
// if (error.isNotBlank()) {
// Text(text = error, color = Color.Red)
// }
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.focusRequester(titleInput)
.onPreviewKeyEvent {
if (it.key == Key.Tab && !it.isShiftPressed) {
descriptionInput.requestFocus()
true
} else {
false
}
},
value = title,
onValueChange = setTitle,
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Text,
capitalization = KeyboardCapitalization.Words,
imeAction = ImeAction.Next
),
label = { Text("Title") },
keyboardActions = KeyboardActions(onNext = {
descriptionInput.requestFocus()
}),
maxLines = 1
)
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.focusRequester(descriptionInput)
.onPreviewKeyEvent {
if (it.key == Key.Tab) {
if (it.isShiftPressed) {
titleInput.requestFocus()
} else {
amountInput.requestFocus()
}
true
} else {
false
}
},
value = description,
onValueChange = setDescription,
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Text,
capitalization = KeyboardCapitalization.Sentences,
imeAction = ImeAction.Next
),
label = { Text("Description") },
keyboardActions = KeyboardActions(onNext = {
amountInput.requestFocus()
}),
)
val keyboardController = LocalSoftwareKeyboardController.current
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.focusRequester(amountInput)
.onPreviewKeyEvent {
if (it.key == Key.Tab && it.isShiftPressed) {
descriptionInput.requestFocus()
true
} else {
false
}
},
value = amount,
onValueChange = setAmount,
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Decimal,
imeAction = ImeAction.Next
),
label = { Text("Amount") },
keyboardActions = KeyboardActions(onNext = {
keyboardController?.hide()
}),
)
val (datePickerVisible, setDatePickerVisible) = remember { mutableStateOf(false) }
DatePicker(
modifier = Modifier.fillMaxWidth(),
date = date,
setDate = setDate,
dialogVisible = datePickerVisible,
setDialogVisible = setDatePickerVisible
)
val (timePickerVisible, setTimePickerVisible) = remember { mutableStateOf(false) }
TimePicker(
modifier = Modifier.fillMaxWidth(),
date = date,
setDate = setDate,
dialogVisible = timePickerVisible,
setDialogVisible = setTimePickerVisible
)
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = spacedBy(8.dp)) {
Row(
modifier = Modifier.clickable {
setExpense(true)
},
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(selected = expense, onClick = { setExpense(true) })
Text(text = "Expense")
}
Row(
modifier = Modifier.clickable {
setExpense(false)
},
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(selected = !expense, onClick = { setExpense(false) })
Text(text = "Income")
}
}
val (categoriesExpanded, setCategoriesExpanded) = remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
modifier = Modifier
.fillMaxWidth(),
expanded = categoriesExpanded,
onExpandedChange = setCategoriesExpanded,
) {
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
value = category?.title ?: "",
onValueChange = {},
readOnly = true,
label = {
Text("Category")
},
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = categoriesExpanded)
}
)
ExposedDropdownMenu(expanded = categoriesExpanded, onDismissRequest = {
setCategoriesExpanded(false)
}) {
categories.forEach { c ->
DropdownMenuItem(
text = { Text(c.title) },
onClick = {
setCategory(c)
setCategoriesExpanded(false)
}
)
}
}
}
Button(
modifier = Modifier.fillMaxWidth(),
onClick = save
) {
Text("Save")
}
}
}
@Composable
@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_NO)
@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_YES)
fun TransactionForm_Preview() {
TwigsApp {
TransactionForm(store = Store(reducers = emptyList()))
}
}

View file

@ -0,0 +1,174 @@
package com.wbrawner.twigs.android.ui.transaction
import android.content.res.Configuration.UI_MODE_NIGHT_NO
import android.content.res.Configuration.UI_MODE_NIGHT_YES
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.wbrawner.twigs.android.ui.TwigsScaffold
import com.wbrawner.twigs.android.ui.base.TwigsApp
import com.wbrawner.twigs.android.ui.util.format
import com.wbrawner.twigs.shared.Store
import com.wbrawner.twigs.shared.transaction.Transaction
import com.wbrawner.twigs.shared.transaction.TransactionAction
import com.wbrawner.twigs.shared.transaction.groupByDate
import kotlinx.datetime.Clock
import kotlinx.datetime.toInstant
import java.text.NumberFormat
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun TransactionsScreen(store: Store) {
val state by store.state.collectAsState()
val budget = state.selectedBudget?.let { id -> state.budgets?.first { it.id == id } }
TwigsScaffold(
store = store,
title = budget?.name ?: "Select a Budget",
onClickFab = {
store.dispatch(TransactionAction.NewTransactionClicked)
}
) {
state.transactions?.let { transactions ->
val transactionGroups =
remember(state.editingTransaction) { transactions.groupByDate() }
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(it)
.padding(start = 8.dp, end = 8.dp, bottom = 8.dp)
) {
transactionGroups.forEach { (timestamp, transactions) ->
item {
Text(
modifier = Modifier.padding(8.dp),
text = timestamp.toInstant().format(LocalContext.current),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold
)
}
itemsIndexed(transactions) { index, transaction ->
TransactionListItem(
modifier = Modifier.animateItemPlacement(),
transaction,
index == 0,
index == transactions.lastIndex
) {
store.dispatch(TransactionAction.SelectTransaction(transaction.id))
}
}
}
}
} ?: Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
if (state.editingTransaction) {
TransactionFormDialog(store = store)
}
}
}
@Composable
fun TransactionListItem(
modifier: Modifier = Modifier,
transaction: Transaction,
isFirst: Boolean,
isLast: Boolean,
onClick: (Transaction) -> Unit
) {
val top = if (isFirst) MaterialTheme.shapes.medium.topStart else CornerSize(0.dp)
val bottom = if (isLast) MaterialTheme.shapes.medium.bottomStart else CornerSize(0.dp)
Row(
modifier = modifier
.background(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = MaterialTheme.shapes.medium.copy(
topStart = top,
topEnd = top,
bottomStart = bottom,
bottomEnd = bottom
)
)
.fillMaxWidth()
.clickable { onClick(transaction) }
.padding(8.dp)
.heightIn(min = 56.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier.weight(1f),
verticalArrangement = spacedBy(4.dp)
) {
Text(transaction.title, style = MaterialTheme.typography.bodyLarge)
if (!transaction.description.isNullOrBlank()) {
Text(
transaction.description!!,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Text(
transaction.amount.toCurrencyString(),
color = if (transaction.expense) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
)
}
}
@Composable
@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_NO)
@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_YES)
fun TransactionListItem_Preview() {
TwigsApp {
TransactionListItem(
transaction = Transaction(
title = "Google Store",
description = "Pixel 7 Pro",
date = Clock.System.now(),
amount = 129999,
budgetId = "budgetId",
expense = true,
createdBy = "createdBy"
),
isFirst = true,
isLast = true
) {}
}
}
fun Long.toCurrencyString(): String =
NumberFormat.getCurrencyInstance().format(this.toDouble() / 100.0)
fun Long.toDecimalString(): String = if (this > 0) {
val decimal = (this.toDouble() / 100.0).toString()
if (decimal.length - decimal.lastIndexOf('.') == 2) {
decimal + '0'
} else {
decimal
}
} else {
""
}

View file

@ -0,0 +1,113 @@
package com.wbrawner.twigs.android.ui.util
import android.content.Context
import android.content.ContextWrapper
import android.text.format.DateFormat
import android.util.Log
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.clickable
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.platform.LocalContext
import androidx.fragment.app.FragmentManager
import com.google.android.material.datepicker.MaterialDatePicker
import com.wbrawner.twigs.android.R
import kotlinx.datetime.Instant
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import java.text.DateFormat.getDateTimeInstance
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DatePicker(
modifier: Modifier,
date: Instant,
setDate: (Instant) -> Unit,
label: String = "Date",
dialogVisible: Boolean,
setDialogVisible: (Boolean) -> Unit
) {
Log.d("DatePicker", "date input: ${date.toEpochMilliseconds()}")
val context = LocalContext.current
OutlinedTextField(
modifier = modifier
.clickable {
Log.d("DatePicker", "click!")
setDialogVisible(true)
}
.focusRequester(FocusRequester())
.onFocusChanged {
setDialogVisible(it.hasFocus)
},
value = date.format(context),
onValueChange = {},
readOnly = true,
label = {
Text(label)
}
)
val dialog = remember {
val localTime = date.toLocalDateTime(TimeZone.UTC).time
MaterialDatePicker.Builder.datePicker()
.setSelection(date.toEpochMilliseconds())
.setTheme(R.style.DateTimePickerDialogTheme)
.build()
.also { picker ->
picker.addOnPositiveButtonClickListener {
setDate(
LocalDateTime(
Instant.fromEpochMilliseconds(it).toLocalDateTime(TimeZone.UTC).date,
localTime
).toInstant(TimeZone.UTC)
)
}
picker.addOnDismissListener {
setDialogVisible(false)
}
}
}
DisposableEffect(key1 = dialogVisible) {
if (dialogVisible) {
context.fragmentManager?.let {
dialog.show(it, null)
}
} else if (dialog.isVisible) {
dialog.dismiss()
}
onDispose {
if (dialog.isVisible) {
dialog.dismiss()
}
}
}
}
val Context.activity: AppCompatActivity?
get() = when (this) {
is AppCompatActivity -> this
is ContextWrapper -> baseContext.activity
else -> null
}
val Context.fragmentManager: FragmentManager?
get() = this.activity?.supportFragmentManager
fun Instant.format(context: Context): String =
DateFormat.getDateFormat(context)
.format(
this.toLocalDateTime(TimeZone.currentSystemDefault()).date.atStartOfDayIn(TimeZone.currentSystemDefault())
.toEpochMilliseconds()
)
fun Instant.formatWithTime(): String = getDateTimeInstance().format(this.toEpochMilliseconds())

View file

@ -0,0 +1,398 @@
package com.wbrawner.twigs.android.ui.util
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.InputChip
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import com.wbrawner.twigs.shared.recurringtransaction.DayOfMonth
import com.wbrawner.twigs.shared.recurringtransaction.DayOfYear
import com.wbrawner.twigs.shared.recurringtransaction.Frequency
import com.wbrawner.twigs.shared.recurringtransaction.Ordinal
import com.wbrawner.twigs.shared.recurringtransaction.capitalizedName
import com.wbrawner.twigs.shared.recurringtransaction.toMonth
import kotlinx.datetime.Clock
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.Month
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import java.time.format.TextStyle
import java.util.Locale
import kotlin.math.min
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FrequencyPicker(frequency: Frequency, setFrequency: (Frequency) -> Unit) {
Column(modifier = Modifier.fillMaxWidth()) {
OutlinedTextField(
modifier = Modifier
.fillMaxWidth(),
value = frequency.count.toString(),
onValueChange = { setFrequency(frequency.update(count = it.toInt())) },
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Next
),
label = { Text("Repeat Every") },
)
val (unitExpanded, setUnitExpanded) = remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
modifier = Modifier
.fillMaxWidth(),
expanded = unitExpanded,
onExpandedChange = setUnitExpanded,
) {
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
value = frequency.name,
onValueChange = {},
readOnly = true,
label = {
Text("Time Unit")
},
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = unitExpanded)
}
)
ExposedDropdownMenu(expanded = unitExpanded, onDismissRequest = {
setUnitExpanded(false)
}) {
DropdownMenuItem(
text = { Text("Daily") },
onClick = {
setFrequency(Frequency.Daily(frequency.count, frequency.time))
setUnitExpanded(false)
}
)
DropdownMenuItem(
text = { Text("Weekly") },
onClick = {
setFrequency(Frequency.Weekly(frequency.count, setOf(), frequency.time))
setUnitExpanded(false)
}
)
DropdownMenuItem(
text = { Text("Monthly") },
onClick = {
setFrequency(
Frequency.Monthly(
frequency.count,
DayOfMonth.FixedDayOfMonth(
Clock.System.now().toLocalDateTime(
TimeZone.UTC
).dayOfMonth
),
frequency.time
)
)
setUnitExpanded(false)
}
)
DropdownMenuItem(
text = { Text("Yearly") },
onClick = {
val today = Clock.System.now().toLocalDateTime(
TimeZone.UTC
)
setFrequency(
Frequency.Yearly(
frequency.count,
DayOfYear.of(today.monthNumber, today.dayOfMonth),
frequency.time
)
)
setUnitExpanded(false)
}
)
}
}
}
when (frequency) {
is Frequency.Daily -> {
// No additional config needed
}
is Frequency.Weekly -> {
WeeklyFrequencyPicker(frequency, setFrequency)
}
is Frequency.Monthly -> {
MonthlyFrequencyPicker(frequency, setFrequency)
}
is Frequency.Yearly -> {
YearlyFrequencyPicker(frequency, setFrequency)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun WeeklyFrequencyPicker(frequency: Frequency.Weekly, setFrequency: (Frequency) -> Unit) {
val daysOfWeek = remember { DayOfWeek.values() }
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
horizontalArrangement = spacedBy(8.dp, Alignment.CenterHorizontally)
) {
daysOfWeek.forEach {
val label = remember(it) { it.getDisplayName(TextStyle.SHORT, Locale.getDefault()) }
InputChip(
selected = frequency.daysOfWeek.contains(it),
onClick = {
val selection = frequency.daysOfWeek.toMutableSet()
if (selection.contains(it)) {
selection.remove(it)
} else {
selection.add(it)
}
setFrequency(frequency.copy(daysOfWeek = selection))
},
label = { Text(label) }
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MonthlyFrequencyPicker(frequency: Frequency.Monthly, setFrequency: (Frequency) -> Unit) {
val (fixedDay, setFixedDay) = remember {
mutableStateOf(
(frequency.dayOfMonth as? DayOfMonth.FixedDayOfMonth)?.day ?: 1
)
}
val (ordinal, setOrdinal) = remember { mutableStateOf((frequency.dayOfMonth as? DayOfMonth.OrdinalDayOfMonth)?.ordinal) }
val (dayOfWeek, setDayOfWeek) = remember {
mutableStateOf(
(frequency.dayOfMonth as? DayOfMonth.OrdinalDayOfMonth)?.dayOfWeek ?: DayOfWeek.SUNDAY
)
}
Row(modifier = Modifier.fillMaxWidth()) {
Box(modifier = Modifier.weight(1f)) {
val (ordinalExpanded, setOrdinalExpanded) = remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
modifier = Modifier
.fillMaxWidth(),
expanded = ordinalExpanded,
onExpandedChange = setOrdinalExpanded,
) {
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
value = ordinal?.capitalizedName ?: "Day",
onValueChange = {},
readOnly = true,
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = ordinalExpanded)
}
)
ExposedDropdownMenu(expanded = ordinalExpanded, onDismissRequest = {
setOrdinalExpanded(false)
}) {
DropdownMenuItem(
text = { Text("Day") },
onClick = {
setOrdinal(null)
setFrequency(
frequency.copy(
dayOfMonth = DayOfMonth.FixedDayOfMonth(
fixedDay
)
)
)
setOrdinalExpanded(false)
}
)
Ordinal.values().forEach { ordinal ->
DropdownMenuItem(
text = { Text(ordinal.capitalizedName) },
onClick = {
setOrdinal(ordinal)
setFrequency(
frequency.copy(
dayOfMonth = DayOfMonth.OrdinalDayOfMonth(
ordinal,
dayOfWeek
)
)
)
setOrdinalExpanded(false)
}
)
}
}
}
}
Spacer(modifier = Modifier.width(8.dp))
Box(modifier = Modifier.weight(1f)) {
val (dayExpanded, setDayExpanded) = remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
modifier = Modifier
.fillMaxWidth(),
expanded = dayExpanded,
onExpandedChange = setDayExpanded,
) {
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
value = ordinal?.let { dayOfWeek.capitalizedName } ?: fixedDay.toString(),
onValueChange = {},
readOnly = true,
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = dayExpanded)
}
)
ExposedDropdownMenu(expanded = dayExpanded, onDismissRequest = {
setDayExpanded(false)
}) {
if (ordinal == null) {
for (day in 1..31) {
DropdownMenuItem(
text = { Text(day.toString()) },
onClick = {
setFixedDay(day)
setFrequency(
frequency.copy(
dayOfMonth = DayOfMonth.FixedDayOfMonth(
day
)
)
)
setDayExpanded(false)
}
)
}
} else {
DayOfWeek.values().forEach { dayOfWeek ->
DropdownMenuItem(
text = { Text(dayOfWeek.capitalizedName) },
onClick = {
setDayOfWeek(dayOfWeek)
setFrequency(
frequency.copy(
dayOfMonth = (frequency.dayOfMonth as DayOfMonth.OrdinalDayOfMonth).copy(
dayOfWeek = dayOfWeek
)
)
)
setDayExpanded(false)
}
)
}
}
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun YearlyFrequencyPicker(frequency: Frequency.Yearly, setFrequency: (Frequency) -> Unit) {
val (month, setMonth) = remember { mutableStateOf(frequency.dayOfYear.month) }
val (day, setDay) = remember { mutableStateOf(frequency.dayOfYear.day) }
Row(modifier = Modifier.fillMaxWidth()) {
Box(modifier = Modifier.weight(1f)) {
val (monthExpanded, setMonthExpanded) = remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
modifier = Modifier
.fillMaxWidth(),
expanded = monthExpanded,
onExpandedChange = setMonthExpanded,
) {
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
value = Month.of(month).getDisplayName(TextStyle.FULL, Locale.getDefault()),
onValueChange = {},
readOnly = true,
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = monthExpanded)
}
)
ExposedDropdownMenu(expanded = monthExpanded, onDismissRequest = {
setMonthExpanded(false)
}) {
Month.values().forEach { m ->
DropdownMenuItem(
text = { Text(m.getDisplayName(TextStyle.FULL, Locale.getDefault())) },
onClick = {
setMonth(m.value)
setFrequency(
frequency.copy(
dayOfYear = DayOfYear.of(m.value, min(day, m.maxLength()))
)
)
setMonthExpanded(false)
}
)
}
}
}
}
Box(modifier = Modifier.weight(1f)) {
val (dayExpanded, setDayExpanded) = remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
modifier = Modifier
.fillMaxWidth(),
expanded = dayExpanded,
onExpandedChange = setDayExpanded,
) {
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
value = day.toString(),
onValueChange = {},
readOnly = true,
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = dayExpanded)
}
)
ExposedDropdownMenu(expanded = dayExpanded, onDismissRequest = {
setDayExpanded(false)
}) {
for (d in 1..month.toMonth().maxLength()) {
DropdownMenuItem(
text = { Text(d.toString()) },
onClick = {
setDay(d)
setFrequency(frequency.copy(dayOfYear = DayOfYear.of(month, d)))
setDayExpanded(false)
}
)
}
}
}
}
}
}

View file

@ -0,0 +1,149 @@
package com.wbrawner.twigs.android.ui.util
import android.app.TimePickerDialog
import android.content.Context
import android.text.format.DateFormat
import androidx.compose.foundation.clickable
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.platform.LocalContext
import com.wbrawner.twigs.shared.recurringtransaction.Time
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Duration.Companion.milliseconds
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TimePicker(
modifier: Modifier,
date: Instant,
setDate: (Instant) -> Unit,
dialogVisible: Boolean,
setDialogVisible: (Boolean) -> Unit
) {
val context = LocalContext.current
OutlinedTextField(
modifier = modifier
.clickable {
setDialogVisible(true)
}
.focusRequester(FocusRequester())
.onFocusChanged {
setDialogVisible(it.hasFocus)
},
value = date.formatTime(context),
onValueChange = {},
readOnly = true,
label = {
Text("Time")
}
)
val dialog = remember {
val localTime = date.toLocalDateTime(TimeZone.currentSystemDefault())
TimePickerDialog(
context,
{ _, hour, minute ->
setDate(
LocalDateTime(
localTime.date,
LocalTime(hour, minute)
).toInstant(TimeZone.UTC)
.minus(java.util.TimeZone.getDefault().rawOffset.milliseconds)
)
},
localTime.hour,
localTime.minute,
DateFormat.is24HourFormat(context)
).also { picker ->
picker.setOnDismissListener {
setDialogVisible(false)
}
}
}
DisposableEffect(key1 = dialogVisible) {
if (dialogVisible) {
context.fragmentManager?.let {
dialog.show()
}
} else if (dialog.isShowing) {
dialog.dismiss()
}
onDispose {
if (dialog.isShowing) {
dialog.dismiss()
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TimePicker(
modifier: Modifier,
time: Time,
setTime: (Time) -> Unit,
dialogVisible: Boolean,
setDialogVisible: (Boolean) -> Unit
) {
val context = LocalContext.current
OutlinedTextField(
modifier = modifier
.clickable {
setDialogVisible(true)
}
.focusRequester(FocusRequester())
.onFocusChanged {
setDialogVisible(it.hasFocus)
},
value = time.toString(),
onValueChange = {},
readOnly = true,
label = {
Text("Time")
}
)
val dialog = remember {
val localTime = Clock.System.now().toLocalDateTime(TimeZone.UTC)
TimePickerDialog(
context,
{ _, hour, minute -> setTime(Time(hour, minute, 0)) },
localTime.hour,
localTime.minute,
DateFormat.is24HourFormat(context)
).also { picker ->
picker.setOnDismissListener {
setDialogVisible(false)
}
}
}
DisposableEffect(key1 = dialogVisible) {
if (dialogVisible) {
context.fragmentManager?.let {
dialog.show()
}
} else if (dialog.isShowing) {
dialog.dismiss()
}
onDispose {
if (dialog.isShowing) {
dialog.dismiss()
}
}
}
}
fun Instant.formatTime(context: Context): String =
DateFormat.getTimeFormat(context).format(this.toEpochMilliseconds())

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

View file

@ -2,8 +2,9 @@
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
android:viewportHeight="24.0"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="#FF000000"
android:fillColor="@android:color/white"
android:pathData="M3,17.25V21h3.75L17.81,9.94l-3.75,-3.75L3,17.25zM20.71,7.04c0.39,-0.39 0.39,-1.02 0,-1.41l-2.34,-2.34c-0.39,-0.39 -1.02,-0.39 -1.41,0l-1.83,1.83 3.75,3.75 1.83,-1.83z" />
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M10,4H4c-1.1,0 -1.99,0.9 -1.99,2L2,18c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2V8c0,-1.1 -0.9,-2 -2,-2h-8l-2,-2z" />
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M20,6h-8l-2,-2L4,4c-1.1,0 -1.99,0.9 -1.99,2L2,18c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2L22,8c0,-1.1 -0.9,-2 -2,-2zM20,18L4,18L4,8h16v10z" />
</vector>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_checked="true" android:drawable="@drawable/ic_folder" />
<item android:drawable="@drawable/ic_folder_open" />
</selector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M3,18h18v-2L3,16v2zM3,13h18v-2L3,11v2zM3,6v2h18L21,6L3,6z"/>
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M19.14,12.94c0.04,-0.3 0.06,-0.61 0.06,-0.94c0,-0.32 -0.02,-0.64 -0.07,-0.94l2.03,-1.58c0.18,-0.14 0.23,-0.41 0.12,-0.61l-1.92,-3.32c-0.12,-0.22 -0.37,-0.29 -0.59,-0.22l-2.39,0.96c-0.5,-0.38 -1.03,-0.7 -1.62,-0.94L14.4,2.81c-0.04,-0.24 -0.24,-0.41 -0.48,-0.41h-3.84c-0.24,0 -0.43,0.17 -0.47,0.41L9.25,5.35C8.66,5.59 8.12,5.92 7.63,6.29L5.24,5.33c-0.22,-0.08 -0.47,0 -0.59,0.22L2.74,8.87C2.62,9.08 2.66,9.34 2.86,9.48l2.03,1.58C4.84,11.36 4.8,11.69 4.8,12s0.02,0.64 0.07,0.94l-2.03,1.58c-0.18,0.14 -0.23,0.41 -0.12,0.61l1.92,3.32c0.12,0.22 0.37,0.29 0.59,0.22l2.39,-0.96c0.5,0.38 1.03,0.7 1.62,0.94l0.36,2.54c0.05,0.24 0.24,0.41 0.48,0.41h3.84c0.24,0 0.44,-0.17 0.47,-0.41l0.36,-2.54c0.59,-0.24 1.13,-0.56 1.62,-0.94l2.39,0.96c0.22,0.08 0.47,0 0.59,-0.22l1.92,-3.32c0.12,-0.22 0.07,-0.47 -0.12,-0.61L19.14,12.94zM12,15.6c-1.98,0 -3.6,-1.62 -3.6,-3.6s1.62,-3.6 3.6,-3.6s3.6,1.62 3.6,3.6S13.98,15.6 12,15.6z" />
</vector>

View file

@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<group
android:scaleX="2.7"
android:scaleY="2.7"
android:translateX="21.6"
android:translateY="21.6">
<path
android:fillColor="@color/colorTextGreen"
android:pathData="M11.8,10.9c-2.27,-0.59 -3,-1.2 -3,-2.15 0,-1.09 1.01,-1.85 2.7,-1.85 1.78,0 2.44,0.85 2.5,2.1h2.21c-0.07,-1.72 -1.12,-3.3 -3.21,-3.81V3h-3v2.16c-1.94,0.42 -3.5,1.68 -3.5,3.61 0,2.31 1.91,3.46 4.7,4.13 2.5,0.6 3,1.48 3,2.41 0,0.69 -0.49,1.79 -2.7,1.79 -2.06,0 -2.87,-0.92 -2.98,-2.1h-2.2c0.12,2.19 1.76,3.42 3.68,3.83V21h3v-2.15c1.95,-0.37 3.5,-1.5 3.5,-3.55 0,-2.84 -2.43,-3.81 -4.7,-4.4z" />
</group>
</vector>

View file

@ -4,7 +4,7 @@
android:width="48dp"
xmlns:android="http://schemas.android.com/apk/res/android">
<path
android:fillColor="#ffffff"
android:fillColor="#FFffffff"
android:pathData="M74.798,72.259L74.798,87.183L74.798,94.176C74.798,117.78 93.703,137.158 117.127,137.925L117.127,137.952L124.59,137.952L126.035,137.952L128.006,137.952L128.006,141.826L128.006,156.282L128.006,167.749C128.006,177.634 132.744,186.941 140.737,192.757C148.729,198.573 159.042,200.217 168.448,197.177L163.857,182.976C158.98,184.553 153.662,183.704 149.519,180.688C145.374,177.672 142.931,172.874 142.931,167.749L142.931,156.282L146.342,156.282L152.361,156.282L153.806,156.282L153.806,156.259C177.231,155.491 196.136,136.113 196.136,112.509L196.136,105.046L196.136,90.59L188.673,90.59L179.243,90.59L177.798,90.59L170.336,90.59L170.336,90.614C158.944,90.988 148.622,95.762 141.023,103.284C135.669,85.812 119.624,72.91 100.596,72.286L100.596,72.262L99.153,72.262L93.134,72.262L82.261,72.262L74.798,72.259zM171.781,105.512L177.798,105.512L179.243,105.512L181.211,105.512L181.211,112.506C181.211,128.527 168.382,141.355 152.361,141.355L146.342,141.355L142.931,141.355L142.931,134.363C142.931,118.342 155.759,105.512 171.781,105.512z"
android:strokeWidth="2.9615941" />
</vector>

View file

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<font-family xmlns:app="http://schemas.android.com/apk/res-auto">
<font
app:font="@font/ubuntu_light"
app:fontStyle="normal"
app:fontWeight="300" />
<font
app:font="@font/ubuntu_lightitalic"
app:fontStyle="italic"
app:fontWeight="300" />
<font
app:font="@font/ubuntu_regular"
app:fontStyle="normal"
app:fontWeight="400" />
<font
app:font="@font/ubuntu_italic"
app:fontStyle="italic"
app:fontWeight="400" />
<font
app:font="@font/ubuntu_medium"
app:fontStyle="normal"
app:fontWeight="500" />
<font
app:font="@font/ubuntu_mediumitalic"
app:fontStyle="italic"
app:fontWeight="500" />
<font
app:font="@font/ubuntu_bold"
app:fontStyle="normal"
app:fontWeight="700" />
<font
app:font="@font/ubuntu_bolditalic"
app:fontStyle="italic"
app:fontWeight="700" />
</font-family>

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -1,101 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.appcompat.widget.Toolbar
android:id="@+id/action_bar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:elevation="4dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ScrollView
android:id="@+id/categoryForm"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/action_bar">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/container_edit_category_name"
style="@style/AppTheme.EditText.Container"
android:hint="@string/prompt_category_name">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/edit_category_name"
style="@style/AppTheme.EditText"
android:inputType="textCapWords" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/container_edit_category_amount"
style="@style/AppTheme.EditText.Container"
android:hint="@string/prompt_category_amount">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/edit_category_amount"
style="@style/AppTheme.EditText"
android:inputType="numberDecimal" />
</com.google.android.material.textfield.TextInputLayout>
<RadioGroup
android:id="@+id/categoryExpenseContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<RadioButton
android:id="@+id/expense"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:checked="true"
android:text="@string/type_expense" />
<RadioButton
android:id="@+id/income"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/type_income" />
</RadioGroup>
<CheckBox
android:id="@+id/archived"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/archived" />
<TextView
android:id="@+id/budgetHint"
style="@style/TextAppearance.MaterialComponents.Caption"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/prompt_budget" />
<androidx.appcompat.widget.AppCompatSpinner
android:id="@+id/budgetSpinner"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</LinearLayout>
</ScrollView>
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -1,170 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:tools="http://schemas.android.com/tools">
<androidx.appcompat.widget.Toolbar
android:id="@+id/action_bar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:elevation="4dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ScrollView
android:id="@+id/scrollView2"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/action_bar">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/container_edit_transaction_title"
style="@style/AppTheme.EditText.Container"
android:hint="@string/prompt_transaction_title"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/edit_transaction_title"
style="@style/AppTheme.EditText"
android:inputType="textCapWords" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/container_edit_transaction_description"
style="@style/AppTheme.EditText.Container"
android:hint="@string/prompt_transaction_description"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/container_edit_transaction_title">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/edit_transaction_description"
style="@style/AppTheme.EditText"
android:inputType="textCapSentences|textMultiLine"
android:scrollHorizontally="false" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/container_edit_transaction_amount"
style="@style/AppTheme.EditText.Container"
android:hint="@string/prompt_transaction_amount"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/container_edit_transaction_description">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/edit_transaction_amount"
style="@style/AppTheme.EditText"
android:inputType="numberDecimal"
android:scrollHorizontally="false" />
</com.google.android.material.textfield.TextInputLayout>
<RadioGroup
android:id="@+id/container_edit_transaction_type"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
android:orientation="horizontal"
app:layout_constraintTop_toBottomOf="@+id/container_edit_transaction_amount">
<RadioButton
android:id="@+id/edit_transaction_type_expense"
android:text="@string/type_expense"
android:checked="true"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
<RadioButton
android:id="@+id/edit_transaction_type_income"
android:text="@string/type_income"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</RadioGroup>
<TextView
android:id="@+id/container_edit_transaction_date"
style="@style/AppTheme.EditText.Hint"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:text="@string/prompt_transaction_date"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/container_edit_transaction_type" />
<TextView
android:layout_height="wrap_content"
android:layout_width="wrap_content"
android:id="@+id/transactionDate"
tools:text="9/23/2019"
android:padding="8dp"
android:textColor="@color/colorTextPrimary"
app:layout_constraintHorizontal_bias="0"
app:layout_constraintHorizontal_chainStyle="packed"
app:layout_constraintEnd_toStartOf="@+id/transactionTime"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/container_edit_transaction_date" />
<TextView
android:layout_height="wrap_content"
android:layout_width="wrap_content"
android:id="@+id/transactionTime"
android:padding="8dp"
android:textColor="@color/colorTextPrimary"
tools:text="10:23 pm"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/transactionDate"
app:layout_constraintTop_toBottomOf="@+id/container_edit_transaction_date" />
<TextView
android:id="@+id/budgetHint"
style="@style/AppTheme.EditText.Hint"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:text="@string/prompt_budget"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/transactionDate" />
<Spinner
android:id="@+id/budgetSpinner"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/budgetHint" />
<TextView
android:id="@+id/container_edit_transaction_category"
style="@style/AppTheme.EditText.Hint"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:text="@string/prompt_transaction_category"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/budgetSpinner" />
<Spinner
android:id="@+id/edit_transaction_category"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/container_edit_transaction_category" />
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -1,11 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<fragment xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/auth_content"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:defaultNavHost="true"
app:navGraph="@navigation/auth_graph"
tools:context=".ui.SplashActivity" />

View file

@ -1,39 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.MainActivity">
<androidx.appcompat.widget.Toolbar
android:id="@+id/action_bar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:elevation="4dp"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<com.google.android.material.bottomnavigation.BottomNavigationView
android:id="@+id/menu_main"
android:layout_width="0dp"
android:layout_height="56dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:menu="@menu/main_navigation" />
<fragment
android:id="@+id/content_container"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="match_parent"
android:layout_height="0dp"
app:defaultNavHost="true"
app:layout_constraintBottom_toTopOf="@+id/menu_main"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@+id/action_bar"
app:navGraph="@navigation/nav_graph" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -1,14 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.budgets.BudgetListFragment">
<!-- TODO: Update blank fragment layout -->
<TextView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:text="@string/hello_blank_fragment" />
</FrameLayout>

View file

@ -1,71 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:animateLayoutChanges="true"
android:fillViewport="true"
android:padding="16dp"
tools:context=".ui.budgets.AddEditBudgetFragment">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/budgetForm"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/descriptionContainer"
style="@style/AppTheme.EditText.Container"
android:hint="@string/prompt_account_description"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/nameContainer">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/description"
style="@style/AppTheme.EditText"
android:inputType="textMultiLine|textCapSentences" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/nameContainer"
style="@style/AppTheme.EditText.Container"
android:hint="@string/prompt_account_name"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/name"
style="@style/AppTheme.EditText"
android:inputType="text|textCapWords" />
</com.google.android.material.textfield.TextInputLayout>
<AutoCompleteTextView
android:id="@+id/usersSearch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/prompt_share_with"
app:layout_constraintTop_toBottomOf="@+id/descriptionContainer" />
<com.google.android.material.chip.ChipGroup
android:id="@+id/usersContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toBottomOf="@+id/usersSearch" />
</androidx.constraintlayout.widget.ConstraintLayout>
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:visibility="gone"
tools:visibility="visible" />
</FrameLayout>
</ScrollView>

View file

@ -1,117 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/categoryDetails"
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:id="@+id/categoryDescription"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/totalLabel"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:paddingHorizontal="8dp"
android:text="@string/label_total"
android:textAlignment="center"
android:textAllCaps="true"
app:layout_constraintEnd_toStartOf="@+id/balanceLabel"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/categoryDescription" />
<TextView
android:id="@+id/balanceLabel"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:paddingHorizontal="8dp"
android:text="@string/label_balance"
android:textAlignment="center"
android:textAllCaps="true"
app:layout_constraintEnd_toStartOf="@+id/remainingLabel"
app:layout_constraintStart_toEndOf="@+id/totalLabel"
app:layout_constraintTop_toBottomOf="@+id/categoryDescription" />
<TextView
android:id="@+id/remainingLabel"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:paddingHorizontal="8dp"
android:text="@string/label_remaining"
android:textAlignment="center"
android:textAllCaps="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/balanceLabel"
app:layout_constraintTop_toBottomOf="@+id/categoryDescription" />
<TextView
android:id="@+id/total"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:padding="8dp"
android:textAlignment="center"
android:textColor="@color/colorTextPrimary"
android:textSize="20sp"
app:layout_constraintEnd_toStartOf="@+id/balance"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/totalLabel"
tools:text="$60.00" />
<TextView
android:id="@+id/balance"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:padding="8dp"
android:textAlignment="center"
android:textColor="@color/colorTextPrimary"
android:textSize="20sp"
app:layout_constraintEnd_toStartOf="@+id/remaining"
app:layout_constraintStart_toEndOf="@+id/total"
app:layout_constraintTop_toBottomOf="@+id/balanceLabel"
tools:text="$40.00" />
<TextView
android:id="@+id/remaining"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:padding="8dp"
android:textAlignment="center"
android:textColor="@color/colorTextPrimary"
android:textSize="20sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/balance"
app:layout_constraintTop_toBottomOf="@+id/remainingLabel"
tools:text="$20.00" />
<ProgressBar
android:id="@+id/categoryProgress"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toBottomOf="@+id/total"
tools:progress="40" />
<FrameLayout
android:id="@+id/transactionsFragmentContainer"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toBottomOf="@+id/categoryProgress" />
</androidx.constraintlayout.widget.ConstraintLayout>
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center" />
</FrameLayout>

View file

@ -1,53 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:animateLayoutChanges="true">
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:id="@+id/listContainer"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="fill_vertical|center_horizontal" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/addFab"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="16dp"
android:clickable="true"
android:focusable="true"
app:backgroundTint="@color/colorPrimary"
app:srcCompat="@drawable/ic_add_white_24dp" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
<androidx.emoji.widget.EmojiTextView
android:id="@+id/noItemsTextView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_margin="8dp"
android:textAlignment="center"
android:textColor="@color/colorTextPrimary"
android:textSize="24sp"
android:visibility="gone"
tools:visibility="visible" />
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:visibility="gone"
tools:visibility="visible" />
</FrameLayout>

View file

@ -1,119 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/loginContainer"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:animateLayoutChanges="true"
android:background="@color/colorBackgroundPrimary"
android:clipChildren="false"
android:clipToPadding="false"
android:fitsSystemWindows="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:clipChildren="false"
android:clipToPadding="false"
android:fitsSystemWindows="true"
android:orientation="vertical"
android:padding="16dp">
<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:contentDescription="@string/description_logo"
android:src="@drawable/ic_launcher_foreground" />
<TextView
android:id="@+id/formPrompt"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/info_login" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/usernameContainer"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:hint="@string/prompt_username"
app:boxCornerRadiusBottomEnd="16dp"
app:boxCornerRadiusBottomStart="16dp"
app:boxCornerRadiusTopEnd="16dp"
app:boxCornerRadiusTopStart="16dp">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/username"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:imeOptions="actionNext"
android:inputType="text"
android:maxLines="1"
android:nextFocusRight="@+id/password"
android:nextFocusDown="@+id/password"
android:nextFocusForward="@+id/password" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/passwordContainer"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:hint="@string/prompt_password"
app:boxCornerRadiusBottomEnd="16dp"
app:boxCornerRadiusBottomStart="16dp"
app:boxCornerRadiusTopEnd="16dp"
app:boxCornerRadiusTopStart="16dp">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/password"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:imeOptions="actionDone"
android:inputType="textPassword"
android:nextFocusLeft="@+id/username"
android:nextFocusUp="@+id/username" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/submit"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/action_login"
app:cornerRadius="16dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/forgotPasswordLink"
style="@style/Widget.MaterialComponents.Button.TextButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/title_forgot_password"
app:cornerRadius="16dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/registerButton"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/title_register"
app:cornerRadius="16dp" />
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:visibility="gone"
tools:visibility="visible" />
</LinearLayout>
</ScrollView>

View file

@ -1,50 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:id="@+id/overviewContent"
android:orientation="vertical"
android:gravity="center"
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:id="@+id/balanceLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:text="@string/label_current_balance" />
<TextView
android:id="@+id/balance"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:textSize="36sp" />
</LinearLayout>
<androidx.emoji.widget.EmojiTextView
android:id="@+id/noData"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_margin="8dp"
android:text="@string/overview_no_data"
android:textAlignment="center"
android:textColor="@color/colorTextPrimary"
android:textSize="24sp"
android:visibility="gone"
tools:visibility="visible" />
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:visibility="gone"
tools:visibility="visible" />
</FrameLayout>

View file

@ -1,14 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.profile.ProfileFragment">
<!-- TODO: Update blank fragment layout -->
<TextView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:text="@string/hello_blank_fragment" />
</FrameLayout>

Some files were not shown because too many files have changed in this diff Show more