diff --git a/androidVariant/src/main/java/org/comixedproject/variant/android/view/HomeView.kt b/androidVariant/src/main/java/org/comixedproject/variant/android/view/HomeView.kt index ec5e1b41..ed04815d 100644 --- a/androidVariant/src/main/java/org/comixedproject/variant/android/view/HomeView.kt +++ b/androidVariant/src/main/java/org/comixedproject/variant/android/view/HomeView.kt @@ -21,6 +21,7 @@ package org.comixedproject.variant.android.view import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold 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 @@ -32,6 +33,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.comixedproject.variant.android.VariantTheme import org.comixedproject.variant.android.view.comics.ComicBookView +import org.comixedproject.variant.android.view.reading.ReadingView import org.comixedproject.variant.android.view.server.ServerView import org.comixedproject.variant.android.view.settings.SettingsView import org.comixedproject.variant.viewmodel.VariantViewModel @@ -42,6 +44,7 @@ fun HomeView() { val variantViewModel: VariantViewModel = koinViewModel() var currentDestination by remember { mutableStateOf(AppDestination.COMICS) } val coroutineScope = rememberCoroutineScope() + val comicBook by variantViewModel.comicBook.collectAsState() Scaffold( topBar = { @@ -60,7 +63,22 @@ fun HomeView() { }, content = { padding -> when (currentDestination) { - AppDestination.COMICS -> ComicBookView(modifier = Modifier.padding(padding)) + AppDestination.COMICS -> + if (comicBook != null) { + ReadingView( + comicBook!!, + modifier = Modifier.padding(padding), + onStopReading = { variantViewModel.readComicBook(null) } + ) + } else { + ComicBookView( + onReadComicBook = { comicBook -> + variantViewModel.readComicBook(comicBook) + }, + modifier = Modifier.padding(padding) + ) + } + AppDestination.BROWSE -> ServerView(modifier = Modifier.padding(padding)) AppDestination.SETTINGS -> SettingsView(onCloseSettings = { currentDestination = AppDestination.COMICS diff --git a/androidVariant/src/main/java/org/comixedproject/variant/android/view/comics/ComicBookListItemView.kt b/androidVariant/src/main/java/org/comixedproject/variant/android/view/comics/ComicBookListItemView.kt index 5b380605..e60648ce 100644 --- a/androidVariant/src/main/java/org/comixedproject/variant/android/view/comics/ComicBookListItemView.kt +++ b/androidVariant/src/main/java/org/comixedproject/variant/android/view/comics/ComicBookListItemView.kt @@ -2,6 +2,7 @@ package org.comixedproject.variant.android.view.comics import android.graphics.BitmapFactory import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material3.CardDefaults @@ -33,7 +34,11 @@ import org.comixedproject.variant.platform.Log private val TAG = "ComicBookListItemView" @Composable -fun ComicBookListItemView(comicBook: ComicBook, modifier: Modifier = Modifier) { +fun ComicBookListItemView( + comicBook: ComicBook, + onClick: (ComicBook) -> Unit, + modifier: Modifier = Modifier +) { var coverContent by remember { mutableStateOf(null) } val coroutineScope = rememberCoroutineScope() @@ -44,7 +49,7 @@ fun ComicBookListItemView(comicBook: ComicBook, modifier: Modifier = Modifier) { ) { val title = MetadataAPI.displayableTitle(comicBook) - Column { + Column(modifier = Modifier.clickable(onClick = { onClick(comicBook) })) { comicBook.pages.firstOrNull()?.let { cover -> if (coverContent == null) { Image( @@ -86,5 +91,5 @@ fun ComicBookListItemView(comicBook: ComicBook, modifier: Modifier = Modifier) { @Composable @Preview fun ComicBookListItemViewPreview() { - VariantTheme { ComicBookListItemView(comicBook = COMIC_BOOK_LIST.get(0)) } + VariantTheme { ComicBookListItemView(comicBook = COMIC_BOOK_LIST.get(0), onClick = {}) } } \ No newline at end of file diff --git a/androidVariant/src/main/java/org/comixedproject/variant/android/view/comics/ComicBookListView.kt b/androidVariant/src/main/java/org/comixedproject/variant/android/view/comics/ComicBookListView.kt index 22d073a5..9aa73583 100644 --- a/androidVariant/src/main/java/org/comixedproject/variant/android/view/comics/ComicBookListView.kt +++ b/androidVariant/src/main/java/org/comixedproject/variant/android/view/comics/ComicBookListView.kt @@ -39,7 +39,11 @@ import org.comixedproject.variant.platform.Log private val TAG = "ComicBookListView" @Composable -fun ComicBookListView(comicBookList: List, modifier: Modifier = Modifier) { +fun ComicBookListView( + comicBookList: List, + onClick: (ComicBook) -> Unit, + modifier: Modifier = Modifier +) { Scaffold( content = { padding -> if (comicBookList.isEmpty()) { @@ -55,7 +59,11 @@ fun ComicBookListView(comicBookList: List, modifier: Modifier = Modif horizontalArrangement = Arrangement.spacedBy(4.dp), content = { items(comicBookList) { comicBook -> - ComicBookListItemView(comicBook, modifier = Modifier.padding(padding)) + ComicBookListItemView( + comicBook, + onClick = { onClick(it) }, + modifier = Modifier.padding(padding) + ) } }) } @@ -67,6 +75,6 @@ fun ComicBookListView(comicBookList: List, modifier: Modifier = Modif @Preview fun ComicBookListViewPreview() { VariantTheme { - ComicBookListView(COMIC_BOOK_LIST) + ComicBookListView(COMIC_BOOK_LIST, onClick = {}) } } \ No newline at end of file diff --git a/androidVariant/src/main/java/org/comixedproject/variant/android/view/comics/ComicBookView.kt b/androidVariant/src/main/java/org/comixedproject/variant/android/view/comics/ComicBookView.kt index 93e1ba4c..6b722b00 100644 --- a/androidVariant/src/main/java/org/comixedproject/variant/android/view/comics/ComicBookView.kt +++ b/androidVariant/src/main/java/org/comixedproject/variant/android/view/comics/ComicBookView.kt @@ -24,21 +24,22 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import org.comixedproject.variant.android.VariantTheme +import org.comixedproject.variant.model.library.ComicBook import org.comixedproject.variant.viewmodel.VariantViewModel import org.koin.androidx.compose.koinViewModel private val TAG = "ComicBookView" @Composable -fun ComicBookView(modifier: Modifier = Modifier) { +fun ComicBookView(onReadComicBook: (ComicBook) -> Unit, modifier: Modifier = Modifier) { val variantViewModel: VariantViewModel = koinViewModel() val comicBookList by variantViewModel.comicBookList.collectAsState() - ComicBookListView(comicBookList, modifier = modifier) + ComicBookListView(comicBookList, onClick = { onReadComicBook(it) }, modifier = modifier) } @Composable @Preview fun ComicBookViewPreview() { - VariantTheme { ComicBookView() } + VariantTheme { ComicBookView(onReadComicBook = { }) } } \ No newline at end of file diff --git a/androidVariant/src/main/java/org/comixedproject/variant/android/view/reading/ReadingPageView.kt b/androidVariant/src/main/java/org/comixedproject/variant/android/view/reading/ReadingPageView.kt new file mode 100644 index 00000000..f079e2a6 --- /dev/null +++ b/androidVariant/src/main/java/org/comixedproject/variant/android/view/reading/ReadingPageView.kt @@ -0,0 +1,177 @@ +/* + * Variant - A digital comic book reading application for the iPad and Android tablets. + * Copyright (C) 2025, The ComiXed Project + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see + */ + +package org.comixedproject.variant.android.view.reading + +import android.graphics.BitmapFactory +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Slider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.graphics.asImageBitmap +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import org.comixedproject.variant.adaptor.ArchiveAPI +import org.comixedproject.variant.android.COMIC_BOOK_LIST +import org.comixedproject.variant.android.R +import org.comixedproject.variant.android.VariantTheme +import org.comixedproject.variant.platform.Log + +private const val TAG = "ReadingPageView" + +@Composable +fun ReadingPageView( + comicFilename: String, + pageFilename: String, + title: String, + currentPage: Int, + totalPages: Int, + onChangePage: (Int) -> Unit, + onStopReading: () -> Unit, + modifier: Modifier = Modifier +) { + var currentPageContent by remember { mutableStateOf(null) } + + Scaffold( + content = { padding -> + if (currentPageContent == null) { + LaunchedEffect(currentPageContent) { + currentPageContent = + ArchiveAPI.loadPage(comicFilename, pageFilename) + } + } else { + currentPageContent?.let { content -> + Image( + bitmap = BitmapFactory.decodeByteArray(content, 0, content.size) + .asImageBitmap(), + contentDescription = title, + modifier = Modifier + .padding(padding) + .fillMaxHeight() + ) + } + } + }, + topBar = { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + IconButton( + onClick = { + Log.debug( + TAG, + "Closing comic book" + ) + onStopReading() + } + ) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.stopReadingLabel) + ) + } + + Text( + title, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth() + ) + } + }, + bottomBar = { + BottomAppBar { + Row(modifier = Modifier.fillMaxWidth()) { + IconButton(onClick = { + currentPageContent = null + onChangePage(currentPage - 1) + }, enabled = (currentPage > 0)) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.previousPageLabel) + ) + } + + Slider( + value = currentPage.toFloat(), + valueRange = 0f..(totalPages - 1).toFloat(), + steps = totalPages, + onValueChange = { + currentPageContent = null + onChangePage(it.toInt()) + }, + modifier = Modifier.weight(0.9f) + ) + + IconButton(onClick = { + currentPageContent = null + onChangePage(currentPage + 1) + }, enabled = (currentPage < (totalPages - 1))) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = stringResource(R.string.nextPageLabel) + ) + } + } + } + }, + modifier = modifier + .fillMaxSize() + ) +} + + +@Composable +@Preview +fun ReadingPageViewPreview() { + val comic = COMIC_BOOK_LIST.get(0) + VariantTheme { + ReadingPageView( + comic.filename, + comic.pages.get(0).filename, + "Page Title", + 5, + 10, + onChangePage = {}, + onStopReading = {} + ) + } +} \ No newline at end of file diff --git a/androidVariant/src/main/java/org/comixedproject/variant/android/view/reading/ReadingView.kt b/androidVariant/src/main/java/org/comixedproject/variant/android/view/reading/ReadingView.kt new file mode 100644 index 00000000..70e4fb6a --- /dev/null +++ b/androidVariant/src/main/java/org/comixedproject/variant/android/view/reading/ReadingView.kt @@ -0,0 +1,58 @@ +/* + * Variant - A digital comic book reading application for the iPad and Android tablets. + * Copyright (C) 2025, The ComiXed Project + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see + */ + +package org.comixedproject.variant.android.view.reading + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import org.comixedproject.variant.android.COMIC_BOOK_LIST +import org.comixedproject.variant.android.VariantTheme +import org.comixedproject.variant.model.library.ComicBook +import org.comixedproject.variant.platform.Log + +private const val TAG = "ReadingView" + +@Composable +fun ReadingView(comicBook: ComicBook, onStopReading: () -> Unit, modifier: Modifier = Modifier) { + var currentPage by remember { mutableIntStateOf(0) } + + ReadingPageView( + comicBook.path, + comicBook.pages.get(currentPage).filename, + comicBook.pages.get(currentPage).filename, + currentPage, + comicBook.pages.size, + onChangePage = { + Log.debug(TAG, "Going to page ${it}") + currentPage = it + }, + onStopReading = onStopReading, + modifier = modifier + ) +} + +@Composable +@Preview +fun ReadingViewPreview() { + VariantTheme { ReadingView(COMIC_BOOK_LIST.get(0), onStopReading = {}) } +} \ No newline at end of file diff --git a/androidVariant/src/main/res/values/strings.xml b/androidVariant/src/main/res/values/strings.xml index eb3040a4..fbb5f56a 100644 --- a/androidVariant/src/main/res/values/strings.xml +++ b/androidVariant/src/main/res/values/strings.xml @@ -13,4 +13,7 @@ No comic books to show... %1$s MB Cancel + Go to the previous page. + Go to the next page. + Stop reading comic book. diff --git a/iosVariant/Variant.xcodeproj/project.pbxproj b/iosVariant/Variant.xcodeproj/project.pbxproj index 4a6d7488..cef96f4d 100644 --- a/iosVariant/Variant.xcodeproj/project.pbxproj +++ b/iosVariant/Variant.xcodeproj/project.pbxproj @@ -14,6 +14,8 @@ 6AA598492DE72E910078CC9F /* ServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AA598482DE72E910078CC9F /* ServerView.swift */; }; 6AA5984C2DE72EF40078CC9F /* Fixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AA5984B2DE72EF40078CC9F /* Fixtures.swift */; }; 6AA598502DE73A8F0078CC9F /* EditServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AA5984F2DE73A8F0078CC9F /* EditServerView.swift */; }; + 6AF012932E1DDF67001A6A19 /* ReadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AF012922E1DDF67001A6A19 /* ReadingView.swift */; }; + 6AF012952E1E89A3001A6A19 /* ReadingPageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AF012942E1E89A3001A6A19 /* ReadingPageView.swift */; }; 7555FF83242A565900829871 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7555FF82242A565900829871 /* HomeView.swift */; }; B62AADC32DFDDF01000BCC0C /* DirectoryEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = B62AADC22DFDDF01000BCC0C /* DirectoryEntry.swift */; }; B67F711E2E1848A1005BFB6B /* ImageLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = B67F711D2E1848A1005BFB6B /* ImageLoader.swift */; }; @@ -52,6 +54,8 @@ 6AA598482DE72E910078CC9F /* ServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerView.swift; sourceTree = ""; }; 6AA5984B2DE72EF40078CC9F /* Fixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Fixtures.swift; sourceTree = ""; }; 6AA5984F2DE73A8F0078CC9F /* EditServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditServerView.swift; sourceTree = ""; }; + 6AF012922E1DDF67001A6A19 /* ReadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadingView.swift; sourceTree = ""; }; + 6AF012942E1E89A3001A6A19 /* ReadingPageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadingPageView.swift; sourceTree = ""; }; 7555FF7B242A565900829871 /* Variant.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Variant.app; sourceTree = BUILT_PRODUCTS_DIR; }; 7555FF82242A565900829871 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = ""; }; 7555FF8C242A565B00829871 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -98,6 +102,15 @@ path = Testing; sourceTree = ""; }; + 6AF012912E1DDF5B001A6A19 /* Reading */ = { + isa = PBXGroup; + children = ( + 6AF012922E1DDF67001A6A19 /* ReadingView.swift */, + 6AF012942E1E89A3001A6A19 /* ReadingPageView.swift */, + ); + path = Reading; + sourceTree = ""; + }; 7555FF72242A565900829871 = { isa = PBXGroup; children = ( @@ -151,10 +164,11 @@ B68FF2E62E08347D0010853B /* Views */ = { isa = PBXGroup; children = ( + B6AEB7942E1024FC003FB705 /* AppDestination.swift */, + B68FF2E72E08348A0010853B /* Comics */, + 6AF012912E1DDF5B001A6A19 /* Reading */, B6AEB7982E102915003FB705 /* Settings */, B6B20B9A2DE508A9008598A7 /* Servers */, - B68FF2E72E08348A0010853B /* Comics */, - B6AEB7942E1024FC003FB705 /* AppDestination.swift */, ); path = Views; sourceTree = ""; @@ -298,6 +312,7 @@ B68FF2ED2E0837610010853B /* ComicBookListItemView.swift in Sources */, 6AA598502DE73A8F0078CC9F /* EditServerView.swift in Sources */, 2152FB042600AC8F00CF470E /* iOSApp.swift in Sources */, + 6AF012932E1DDF67001A6A19 /* ReadingView.swift in Sources */, B67F711E2E1848A1005BFB6B /* ImageLoader.swift in Sources */, B68922552DFDC34E00BDC032 /* FileItemView.swift in Sources */, B6B20B9E2DE50C0E008598A7 /* Koin.swift in Sources */, @@ -311,6 +326,7 @@ B6B20B942DE5048E008598A7 /* KMPObservableViewModel.swift in Sources */, B689224F2DFDA77C00BDC032 /* BrowseServerView.swift in Sources */, B6AEB7952E1024FC003FB705 /* AppDestination.swift in Sources */, + 6AF012952E1E89A3001A6A19 /* ReadingPageView.swift in Sources */, B68FF2EB2E0834EF0010853B /* ComicBookListView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -457,7 +473,7 @@ OTHER_LDFLAGS = ( "$(inherited)", "-framework", - Variant, + shared, ); PRODUCT_BUNDLE_IDENTIFIER = org.comixedproject.iosVariant; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/iosVariant/iosVariant/Data/ImageLoader.swift b/iosVariant/iosVariant/Data/ImageLoader.swift index fc864b99..0dfa8817 100644 --- a/iosVariant/iosVariant/Data/ImageLoader.swift +++ b/iosVariant/iosVariant/Data/ImageLoader.swift @@ -23,29 +23,35 @@ import shared private let TAG = "ImageLoader" class ImageLoader: ObservableObject { - private var comicBook: ComicBook + private var comicFilename: String = "" private var pageFilename: String = "" @Published var image: UIImage? = nil init(comicBook: ComicBook) { - self.comicBook = comicBook + self.comicFilename = comicBook.path - if self.comicBook.pages.count > 0 { - self.pageFilename = (self.comicBook.pages[0] as! ComicPage).filename + if comicBook.pages.count > 0 { + self.pageFilename = (comicBook.pages[0] as! ComicPage).filename doLoadPage() } } + init(comicFilename: String, pageFileanme: String) { + self.comicFilename = comicFilename + self.pageFilename = pageFileanme + doLoadPage() + } + private func doLoadPage() { Task { Log().debug( tag: TAG, message: - "Loading cover image: \(comicBook.path):\(self.pageFilename)" + "Loading cover image: \(self.comicFilename):\(self.pageFilename)" ) let imageData = try await ArchiveAPI().loadPage( - comicFilename: comicBook.path, + comicFilename: self.comicFilename, pageFilename: self.pageFilename ) diff --git a/iosVariant/iosVariant/HomeView.swift b/iosVariant/iosVariant/HomeView.swift index 2c87b05b..332737e3 100644 --- a/iosVariant/iosVariant/HomeView.swift +++ b/iosVariant/iosVariant/HomeView.swift @@ -28,9 +28,12 @@ struct HomeView: View { var body: some View { TabView(selection: $currentDestination) { - ComicBooksView() - .tag(AppDestination.comics) - .tabItem { Label("Comics", systemImage: "book.fill") } + ComicBooksView(onReadComicBook: { comicBook in + variantViewModel.readComicBook(comicBook: comicBook) + currentDestination = .comics + }) + .tag(AppDestination.comics) + .tabItem { Label("Comics", systemImage: "book.fill") } ServerView() .tag(AppDestination.browseServer) diff --git a/iosVariant/iosVariant/Views/Comics/ComicBookListView.swift b/iosVariant/iosVariant/Views/Comics/ComicBookListView.swift index 23467d0e..1fc42461 100644 --- a/iosVariant/iosVariant/Views/Comics/ComicBookListView.swift +++ b/iosVariant/iosVariant/Views/Comics/ComicBookListView.swift @@ -27,6 +27,8 @@ struct ComicBookListView: View { let columns = [GridItem(.adaptive(minimum: 128))] + var onComicBookClicked: (ComicBook) -> Void + var body: some View { NavigationStack { ScrollView { @@ -34,7 +36,9 @@ struct ComicBookListView: View { ForEach(comicBookList, id: \.path) { comicBook in ComicBookListItemView( comicBook: comicBook, - onComicBookClicked: { _ in } + onComicBookClicked: { comicBook in + onComicBookClicked(comicBook) + } ) } } @@ -47,6 +51,7 @@ struct ComicBookListView: View { #Preview { ComicBookListView( - comicBookList: COMIC_BOOK_LIST + comicBookList: COMIC_BOOK_LIST, + onComicBookClicked: { _ in } ) } diff --git a/iosVariant/iosVariant/Views/Comics/ComicBooksView.swift b/iosVariant/iosVariant/Views/Comics/ComicBooksView.swift index 1b9fd384..94a7f265 100644 --- a/iosVariant/iosVariant/Views/Comics/ComicBooksView.swift +++ b/iosVariant/iosVariant/Views/Comics/ComicBooksView.swift @@ -20,14 +20,28 @@ import KMPObservableViewModelSwiftUI import SwiftUI import shared +private let TAG = "ComicBooksView" + struct ComicBooksView: View { @EnvironmentViewModel var variantViewModel: VariantViewModel + var onReadComicBook: (ComicBook) -> Void + var body: some View { - ComicBookListView(comicBookList: self.variantViewModel.comicBookList) + if (variantViewModel.comicBook != nil) { + ReadingView(comicBook: self.variantViewModel.comicBook!, onStopReading: { + Log().debug(tag: TAG, message: "Stop reading comic book") + variantViewModel.readComicBook(comicBook: nil) + }) + } else { + ComicBookListView( + comicBookList: self.variantViewModel.comicBookList, + onComicBookClicked: { comicBook in onReadComicBook(comicBook) } + ) + } } } #Preview { - ComicBooksView() + ComicBooksView(onReadComicBook: { _ in }) } diff --git a/iosVariant/iosVariant/Views/Reading/ReadingPageView.swift b/iosVariant/iosVariant/Views/Reading/ReadingPageView.swift new file mode 100644 index 00000000..630ecbd3 --- /dev/null +++ b/iosVariant/iosVariant/Views/Reading/ReadingPageView.swift @@ -0,0 +1,138 @@ +/* + * Variant - A digital comic book reading application for the iPad and Android tablets. + * Copyright (C) 2025, The ComiXed Project + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see + */ + +import SwiftUI +import shared + +private let TAG = "ReadingPageView" + +struct ReadingPageView: View { + @ObservedObject var imageLoader: ImageLoader + @Binding var pageNumber: Double + + let comicFilename: String + let pageFilename: String + let title: String + let totalPages: Double + + var onStopReading: () -> Void + + init( + comicFilename: String, + pageFilename: String, + title: String, + pageNumber: Binding, + totalPages: Int, + onStopReading: @escaping () -> Void + ) { + self.comicFilename = comicFilename + self.pageFilename = pageFilename + self.title = title + self._pageNumber = pageNumber + self.totalPages = Double(totalPages) + self.onStopReading = onStopReading + + self.imageLoader = ImageLoader( + comicFilename: self.comicFilename, + pageFileanme: self.pageFilename + ) + } + + var maxPage: Double { + return self.totalPages - 1 + } + + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading) { + if imageLoader.image != nil { + Image(uiImage: imageLoader.image!) + .resizable() + .aspectRatio(contentMode: .fill) + } else { + Image(systemName: "placeholdertext.fill") + .resizable() + .aspectRatio(contentMode: .fill) + } + + HStack(alignment: .center) { + Button { + Log().info( + tag: TAG, + message: "Going to previous page" + ) + self.pageNumber = self.pageNumber - 1 + } label: { + Image(systemName: "arrowshape.left.fill") + } + .disabled(pageNumber == 0) + + Slider( + value: $pageNumber, + in: 0...maxPage, + step: 1, + onEditingChanged: { editing in + if !editing { + Log().debug( + tag: TAG, + message: "Going to page \(pageNumber)" + ) + } + } + ) + + Button { + Log().info(tag: TAG, message: "Going to next page") + self.pageNumber = self.pageNumber + 1 + } label: { + Image(systemName: "arrowshape.right.fill") + } + .disabled(pageNumber == (totalPages - 1)) + } + } + .onTapGesture { + Log().debug(tag: TAG, message: "Comic page tapped") + } + .navigationTitle(title) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Close") { + Log().info( + tag: TAG, + message: "Closing comic book" + ) + onStopReading() + } + } + } + } + } + } +} + +#Preview { + ReadingPageView( + comicFilename: COMIC_BOOK_LIST[0].filename, + pageFilename: (COMIC_BOOK_LIST[0].pages[0] as! ComicPage).filename, + title: (COMIC_BOOK_LIST[0].pages[0] as! ComicPage).filename, + pageNumber: .constant(5.0), + totalPages: 10, + onStopReading: {} + ) +} diff --git a/iosVariant/iosVariant/Views/Reading/ReadingView.swift b/iosVariant/iosVariant/Views/Reading/ReadingView.swift new file mode 100644 index 00000000..762875d3 --- /dev/null +++ b/iosVariant/iosVariant/Views/Reading/ReadingView.swift @@ -0,0 +1,58 @@ +/* + * Variant - A digital comic book reading application for the iPad and Android tablets. + * Copyright (C) 2025, The ComiXed Project + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see + */ + +import KMPObservableViewModelSwiftUI +import SwiftUI +import shared + +private let TAG = "ReadingView" + +struct ReadingView: View { + @State private var currentPage = 0.0 + + let comicBook: ComicBook + + var onStopReading: () -> Void + + var comicFilename: String { + return comicBook.path + } + + var pageFilename: String { + return (comicBook.pages[Int(currentPage)] as! ComicPage).filename + } + + var title: String { + return (comicBook.pages[Int(currentPage)] as! ComicPage).filename + } + + var body: some View { + ReadingPageView( + comicFilename: comicFilename, + pageFilename: pageFilename, + title: title, + pageNumber: $currentPage, + totalPages: comicBook.pages.count, + onStopReading: { onStopReading() } + ) + } +} + +#Preview { + ReadingView(comicBook: COMIC_BOOK_LIST[0], onStopReading: {}) +} diff --git a/shared/src/commonMain/kotlin/org/comixedproject/variant/reader/ReaderAPI.kt b/shared/src/commonMain/kotlin/org/comixedproject/variant/reader/ReaderAPI.kt index 42d50830..3bf43922 100644 --- a/shared/src/commonMain/kotlin/org/comixedproject/variant/reader/ReaderAPI.kt +++ b/shared/src/commonMain/kotlin/org/comixedproject/variant/reader/ReaderAPI.kt @@ -41,7 +41,6 @@ import io.ktor.http.Url import io.ktor.serialization.kotlinx.json.json import io.ktor.utils.io.ByteReadChannel import io.ktor.utils.io.core.remaining -import io.ktor.utils.io.exhausted import io.ktor.utils.io.readRemaining import kotlinx.io.readByteArray import kotlinx.serialization.json.Json @@ -102,10 +101,13 @@ public object ReaderAPI { accept(ContentType.Application.OctetStream) }.execute { httpResponse -> val channel: ByteReadChannel = httpResponse.body() - while (!channel.exhausted()) { - val chunk = channel.readRemaining() - Log.debug(TAG, "Writing ${chunk.remaining} bytes to output") - output.write(ByteBuffer(bytes = chunk.readByteArray())) + while (!channel.isClosedForRead) { + val packet = channel.readRemaining((102400).toLong()) + Log.debug(TAG, "Writing ${packet.remaining} bytes to output") + while (!packet.exhausted()) { + val bytes = packet.readByteArray() + output.write(ByteBuffer(bytes)) + } } } } diff --git a/shared/src/commonMain/kotlin/org/comixedproject/variant/viewmodel/VariantViewModel.kt b/shared/src/commonMain/kotlin/org/comixedproject/variant/viewmodel/VariantViewModel.kt index ec3210c9..dcd48652 100644 --- a/shared/src/commonMain/kotlin/org/comixedproject/variant/viewmodel/VariantViewModel.kt +++ b/shared/src/commonMain/kotlin/org/comixedproject/variant/viewmodel/VariantViewModel.kt @@ -111,6 +111,11 @@ open class VariantViewModel( get() = settings.getString(PASSWORD_SETTING, "") set(value) = settings.putString(PASSWORD_SETTING, value) + var _comicBook = MutableStateFlow(viewModelScope, null) + + @NativeCoroutinesState + var comicBook: StateFlow = _comicBook.asStateFlow() + private val _browsingState = MutableStateFlow( viewModelScope, BrowsingState( READER_ROOT, @@ -239,6 +244,17 @@ open class VariantViewModel( } } + fun readComicBook(comicBook: ComicBook?) { + viewModelScope.launch(Dispatchers.Main) { + if (comicBook != null) { + Log.info(TAG, "Reading comic book: ${comicBook.filename}") + } else { + Log.info(TAG, "Stopped reading comic book") + } + _comicBook.emit(comicBook) + } + } + private suspend fun loadLibraryContents() { Log.debug(TAG, "Loading library contents: ${_libraryDirectory}") diff --git a/shared/src/commonMain/sqldelight/org/comixedproject/variant/database/Table.sq b/shared/src/commonMain/sqldelight/org/comixedproject/variant/database/Table.sq index b85bccb7..5183a68a 100644 --- a/shared/src/commonMain/sqldelight/org/comixedproject/variant/database/Table.sq +++ b/shared/src/commonMain/sqldelight/org/comixedproject/variant/database/Table.sq @@ -22,4 +22,4 @@ deleteDirectoryContents: DELETE FROM DirectoriesDb WHERE parent = ?; findDirectory: -SELECT * FROM DirectoriesDb WHERE path = ?; \ No newline at end of file +SELECT * FROM DirectoriesDb WHERE path = ? LIMIT 1; \ No newline at end of file