Skip to content

Commit 88fa7da

Browse files
authored
Merge pull request #31 from fmasa/feat/export
Compendium import & export
2 parents 0533acc + 4d43273 commit 88fa7da

File tree

36 files changed

+1087
-110
lines changed

36 files changed

+1087
-110
lines changed

common/src/androidMain/kotlin/cz/frantisekmasa/wfrp_master/common/core/shared/FileChooser.kt

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,11 @@ import kotlinx.coroutines.CoroutineScope
1111
import kotlinx.coroutines.Dispatchers
1212
import kotlinx.coroutines.launch
1313
import java.io.InputStream
14+
import java.io.OutputStream
1415

1516
@Composable
1617
actual fun rememberFileChooser(
17-
onFileChoose: suspend CoroutineScope.(Result<File>) -> Unit
18+
onFileChoose: suspend CoroutineScope.(Result<ReadableFile>) -> Unit
1819
): FileChooser {
1920
val coroutineScope = rememberCoroutineScope()
2021
val context = LocalContext.current
@@ -31,20 +32,71 @@ actual fun rememberFileChooser(
3132
onFileChoose(
3233
if (inputStream == null)
3334
Result.failure(Exception("Could not open input stream"))
34-
else Result.success(File(inputStream))
35+
else Result.success(ReadableFile(inputStream))
3536
)
3637
}
3738
}
3839

3940
return AndroidFileChooser(launcher)
4041
}
4142

42-
actual class File(
43+
@Composable
44+
actual fun rememberFileSaver(
45+
type: FileType,
46+
defaultFileName: String,
47+
onLocationChoose: suspend CoroutineScope.(Result<WriteableFile>) -> Unit,
48+
): FileSaver {
49+
val coroutineScope = rememberCoroutineScope()
50+
val context = LocalContext.current
51+
52+
val contract = ActivityResultContracts.CreateDocument(
53+
when (type) {
54+
FileType.IMAGE -> "image/jpeg"
55+
FileType.PDF -> "application/pdf"
56+
FileType.JSON -> "application/json"
57+
}
58+
)
59+
60+
val launcher = rememberLauncherForActivityResult(contract) { uri ->
61+
coroutineScope.launch(Dispatchers.IO) {
62+
if (uri == null) {
63+
onLocationChoose(Result.failure(Exception("URI not selected")))
64+
return@launch
65+
}
66+
67+
val outputStream = context.contentResolver.openOutputStream(uri)
68+
69+
onLocationChoose(
70+
if (outputStream == null)
71+
Result.failure(Exception("Could not open output stream"))
72+
else Result.success(WriteableFile(outputStream))
73+
)
74+
}
75+
}
76+
77+
return FileSaver {
78+
launcher.launch(defaultFileName)
79+
}
80+
}
81+
82+
actual class ReadableFile(
4383
actual val stream: InputStream
4484
) {
4585
actual fun readBytes(): ByteArray = stream.readBytes()
4686
}
4787

88+
actual class WriteableFile(
89+
private val stream: OutputStream
90+
) {
91+
actual fun writeBytes(bytes: ByteArray) {
92+
stream.write(bytes)
93+
}
94+
95+
actual fun close() {
96+
stream.close()
97+
}
98+
}
99+
48100
class AndroidFileChooser(
49101
private val launcher: ManagedActivityResultLauncher<String, Uri?>
50102
) : FileChooser {
@@ -53,6 +105,7 @@ class AndroidFileChooser(
53105
when (type) {
54106
FileType.IMAGE -> "image/*"
55107
FileType.PDF -> "application/pdf"
108+
FileType.JSON -> "application/json"
56109
}
57110
)
58111
}

common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/CompendiumScreen.kt

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,17 +36,17 @@ import cz.frantisekmasa.wfrp_master.common.compendium.tabs.TalentCompendiumTab
3636
import cz.frantisekmasa.wfrp_master.common.compendium.tabs.TraitCompendiumTab
3737
import cz.frantisekmasa.wfrp_master.common.core.domain.compendium.CompendiumItem
3838
import cz.frantisekmasa.wfrp_master.common.core.domain.party.PartyId
39-
import cz.frantisekmasa.wfrp_master.common.core.shared.IO
4039
import cz.frantisekmasa.wfrp_master.common.core.ui.buttons.BackButton
4140
import cz.frantisekmasa.wfrp_master.common.core.ui.dialogs.DialogState
4241
import cz.frantisekmasa.wfrp_master.common.core.ui.flow.collectWithLifecycle
42+
import cz.frantisekmasa.wfrp_master.common.core.ui.menu.DropdownMenuItem
4343
import cz.frantisekmasa.wfrp_master.common.core.ui.menu.WithContextMenu
4444
import cz.frantisekmasa.wfrp_master.common.core.ui.primitives.ContextMenu
4545
import cz.frantisekmasa.wfrp_master.common.core.ui.primitives.FullScreenProgress
4646
import cz.frantisekmasa.wfrp_master.common.core.ui.primitives.Spacing
4747
import cz.frantisekmasa.wfrp_master.common.core.ui.primitives.rememberScreenModel
48+
import cz.frantisekmasa.wfrp_master.common.core.ui.scaffolding.OptionsAction
4849
import cz.frantisekmasa.wfrp_master.common.core.ui.scaffolding.Subtitle
49-
import cz.frantisekmasa.wfrp_master.common.core.ui.scaffolding.TopBarAction
5050
import cz.frantisekmasa.wfrp_master.common.core.ui.scaffolding.tabs.TabPager
5151
import cz.frantisekmasa.wfrp_master.common.core.ui.scaffolding.tabs.tab
5252
import cz.frantisekmasa.wfrp_master.common.localization.LocalStrings
@@ -93,10 +93,25 @@ class CompendiumScreen(
9393
},
9494
navigationIcon = { BackButton() },
9595
actions = {
96-
TopBarAction(
97-
text = strings.buttonImport,
98-
onClick = { navigator.push(CompendiumImportScreen(partyId)) }
99-
)
96+
OptionsAction {
97+
DropdownMenuItem(
98+
onClick = { navigator.push(RulebookCompendiumImportScreen(partyId)) }
99+
) {
100+
Text(strings.buttonImportFromRulebook)
101+
}
102+
103+
DropdownMenuItem(
104+
onClick = { navigator.push(JsonCompendiumImportScreen(partyId)) }
105+
) {
106+
Text(strings.buttonImportFile)
107+
}
108+
109+
DropdownMenuItem(
110+
onClick = { navigator.push(JsonCompendiumExportScreen(partyId)) }
111+
) {
112+
Text(strings.buttonExportFile)
113+
}
114+
}
100115
}
101116
)
102117
}

common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/CompendiumScreenModel.kt

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,25 @@ import cz.frantisekmasa.wfrp_master.common.compendium.domain.Skill
88
import cz.frantisekmasa.wfrp_master.common.compendium.domain.Spell
99
import cz.frantisekmasa.wfrp_master.common.compendium.domain.Talent
1010
import cz.frantisekmasa.wfrp_master.common.compendium.domain.Trait
11+
import cz.frantisekmasa.wfrp_master.common.compendium.import.BlessingImport
12+
import cz.frantisekmasa.wfrp_master.common.compendium.import.CareerImport
13+
import cz.frantisekmasa.wfrp_master.common.compendium.import.CompendiumBundle
14+
import cz.frantisekmasa.wfrp_master.common.compendium.import.MiracleImport
15+
import cz.frantisekmasa.wfrp_master.common.compendium.import.SkillImport
16+
import cz.frantisekmasa.wfrp_master.common.compendium.import.SpellImport
17+
import cz.frantisekmasa.wfrp_master.common.compendium.import.TalentImport
18+
import cz.frantisekmasa.wfrp_master.common.compendium.import.TraitImport
1119
import cz.frantisekmasa.wfrp_master.common.core.domain.compendium.Compendium
1220
import cz.frantisekmasa.wfrp_master.common.core.domain.party.Party
1321
import cz.frantisekmasa.wfrp_master.common.core.domain.party.PartyId
1422
import cz.frantisekmasa.wfrp_master.common.core.domain.party.PartyRepository
1523
import cz.frantisekmasa.wfrp_master.common.core.utils.right
24+
import kotlinx.coroutines.async
25+
import kotlinx.coroutines.coroutineScope
1626
import kotlinx.coroutines.flow.Flow
27+
import kotlinx.coroutines.flow.first
28+
import kotlinx.serialization.json.Json
29+
import kotlinx.serialization.serializer
1730

1831
class CompendiumScreenModel(
1932
private val partyId: PartyId,
@@ -119,4 +132,34 @@ class CompendiumScreenModel(
119132
suspend fun remove(career: Career) {
120133
careerCompendium.remove(partyId, career)
121134
}
135+
136+
suspend fun buildExportJson(): String {
137+
return coroutineScope {
138+
val skills = async { skills.first().map(SkillImport::fromSkill) }
139+
val talents = async { talents.first().map(TalentImport::fromTalent) }
140+
val spells = async { spells.first().map(SpellImport::fromSpell) }
141+
val blessings = async { blessings.first().map(BlessingImport::fromBlessing) }
142+
val miracles = async { miracles.first().map(MiracleImport::fromMiracle) }
143+
val traits = async { traits.first().map(TraitImport::fromTrait) }
144+
val careers = async { careers.first().map(CareerImport::fromCareer) }
145+
146+
val bundle = CompendiumBundle(
147+
skills = skills.await(),
148+
talents = talents.await(),
149+
spells = spells.await(),
150+
blessings = blessings.await(),
151+
miracles = miracles.await(),
152+
traits = traits.await(),
153+
careers = careers.await(),
154+
)
155+
156+
json.encodeToString(serializer(), bundle)
157+
}
158+
}
159+
160+
companion object {
161+
private val json = Json {
162+
encodeDefaults = true
163+
}
164+
}
122165
}

common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/ImportDialog.kt

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ import cz.frantisekmasa.wfrp_master.common.compendium.domain.Talent
3333
import cz.frantisekmasa.wfrp_master.common.compendium.domain.Trait
3434
import cz.frantisekmasa.wfrp_master.common.core.domain.compendium.CompendiumItem
3535
import cz.frantisekmasa.wfrp_master.common.core.domain.party.PartyId
36-
import cz.frantisekmasa.wfrp_master.common.core.shared.IO
3736
import cz.frantisekmasa.wfrp_master.common.core.ui.buttons.CloseButton
3837
import cz.frantisekmasa.wfrp_master.common.core.ui.dialogs.FullScreenDialog
3938
import cz.frantisekmasa.wfrp_master.common.core.ui.flow.collectWithLifecycle
@@ -61,7 +60,6 @@ internal fun ImportDialog(
6160
is ImportDialogState.PickingItemsToImport -> ImportedItemsPicker(
6261
screenModel = screenModel,
6362
state = state,
64-
partyId = partyId,
6563
onDismissRequest = onDismissRequest,
6664
onComplete = onComplete,
6765
)
@@ -72,7 +70,6 @@ internal fun ImportDialog(
7270
@Composable
7371
private fun ImportedItemsPicker(
7472
screenModel: CompendiumScreenModel,
75-
partyId: PartyId,
7673
state: ImportDialogState.PickingItemsToImport,
7774
onDismissRequest: () -> Unit,
7875
onComplete: () -> Unit,
@@ -90,6 +87,7 @@ private fun ImportedItemsPicker(
9087
onContinue = { screen = ItemsScreen.TALENTS },
9188
onClose = onDismissRequest,
9289
existingItems = screenModel.skills,
90+
replaceExistingByDefault = state.replaceExistingByDefault,
9391
)
9492
}
9593
ItemsScreen.TALENTS -> {
@@ -100,6 +98,7 @@ private fun ImportedItemsPicker(
10098
onContinue = { screen = ItemsScreen.SPELLS },
10199
onClose = onDismissRequest,
102100
existingItems = screenModel.talents,
101+
replaceExistingByDefault = state.replaceExistingByDefault,
103102
)
104103
}
105104
ItemsScreen.SPELLS -> {
@@ -110,6 +109,7 @@ private fun ImportedItemsPicker(
110109
onContinue = { screen = ItemsScreen.BLESSINGS },
111110
onClose = onDismissRequest,
112111
existingItems = screenModel.spells,
112+
replaceExistingByDefault = state.replaceExistingByDefault,
113113
)
114114
}
115115
ItemsScreen.BLESSINGS -> {
@@ -120,6 +120,7 @@ private fun ImportedItemsPicker(
120120
onContinue = { screen = ItemsScreen.MIRACLES },
121121
onClose = onDismissRequest,
122122
existingItems = screenModel.blessings,
123+
replaceExistingByDefault = state.replaceExistingByDefault,
123124
)
124125
}
125126
ItemsScreen.MIRACLES -> {
@@ -130,6 +131,7 @@ private fun ImportedItemsPicker(
130131
onContinue = { screen = ItemsScreen.TRAITS },
131132
onClose = onDismissRequest,
132133
existingItems = screenModel.miracles,
134+
replaceExistingByDefault = state.replaceExistingByDefault,
133135
)
134136
}
135137
ItemsScreen.TRAITS -> {
@@ -140,6 +142,7 @@ private fun ImportedItemsPicker(
140142
onContinue = { screen = ItemsScreen.CAREERS },
141143
onClose = onDismissRequest,
142144
existingItems = screenModel.traits,
145+
replaceExistingByDefault = state.replaceExistingByDefault,
143146
)
144147
}
145148
ItemsScreen.CAREERS -> {
@@ -150,6 +153,7 @@ private fun ImportedItemsPicker(
150153
onContinue = onComplete,
151154
onClose = onDismissRequest,
152155
existingItems = screenModel.careers,
156+
replaceExistingByDefault = state.replaceExistingByDefault,
153157
)
154158
}
155159
}
@@ -163,6 +167,7 @@ private fun <T : CompendiumItem<T>> ItemPicker(
163167
onContinue: () -> Unit,
164168
existingItems: Flow<List<T>>,
165169
items: List<T>,
170+
replaceExistingByDefault: Boolean,
166171
) {
167172
val existingItemsList = existingItems.collectWithLifecycle(null).value
168173

@@ -179,12 +184,13 @@ private fun <T : CompendiumItem<T>> ItemPicker(
179184
return
180185
}
181186

182-
val existingItemNames = remember(existingItemsList) {
183-
existingItemsList.map { it.name }.toHashSet()
187+
val existingItemsByName = remember(existingItemsList) {
188+
existingItemsList.associateBy { it.name }
184189
}
185190

186-
val selectedItems = remember(items, existingItemNames) {
187-
items.map { it.id to !existingItemNames.contains(it.name) }.toMutableStateMap()
191+
val selectedItems = remember(items, existingItemsByName, replaceExistingByDefault) {
192+
items.map { it.id to (replaceExistingByDefault || it.name !in existingItemsByName) }
193+
.toMutableStateMap()
188194
}
189195
val atLeastOneSelected = selectedItems.containsValue(true)
190196

@@ -212,7 +218,20 @@ private fun <T : CompendiumItem<T>> ItemPicker(
212218

213219
if (atLeastOneSelected) {
214220
withContext(Dispatchers.IO) {
215-
onSave(items.filter { selectedItems.contains(it.id) })
221+
onSave(
222+
items
223+
.asSequence()
224+
.filter { selectedItems[it.id] == true }
225+
.map {
226+
val existingItem = existingItemsByName[it.name]
227+
228+
if (existingItem != null)
229+
it.replace(existingItem)
230+
else it
231+
}
232+
.distinctBy { it.id }
233+
.toList()
234+
)
216235
}
217236
}
218237

@@ -253,8 +272,14 @@ private fun <T : CompendiumItem<T>> ItemPicker(
253272
onValueChange = { selectedItems[item.id] = it },
254273
),
255274
text = { Text(item.name) },
256-
secondaryText = if (existingItemNames.contains(item.name)) {
257-
{ Text(strings.compendium.messages.itemAlreadyExists) }
275+
secondaryText = if (item.name in existingItemsByName) {
276+
{
277+
Text(
278+
if (selectedItems[item.id] == true)
279+
strings.compendium.messages.willReplaceExistingItem
280+
else strings.compendium.messages.itemAlreadyExists
281+
)
282+
}
258283
} else null
259284
)
260285
}
@@ -265,7 +290,7 @@ private fun <T : CompendiumItem<T>> ItemPicker(
265290
}
266291

267292
@Immutable
268-
internal sealed class ImportDialogState {
293+
sealed class ImportDialogState {
269294
@Immutable
270295
object LoadingItems : ImportDialogState()
271296

@@ -278,6 +303,7 @@ internal sealed class ImportDialogState {
278303
val miracles: List<Miracle>,
279304
val traits: List<Trait>,
280305
val careers: List<Career>,
306+
val replaceExistingByDefault: Boolean,
281307
) : ImportDialogState()
282308
}
283309

0 commit comments

Comments
 (0)