diff --git a/app/build.gradle b/app/build.gradle index 2fbbb209f85..4b7ee363f31 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -203,7 +203,8 @@ dependencies { implementation libs.photoview implementation libs.balloon implementation libs.retrofit2.kotlinx.serialization.converter - implementation(libs.paging.compose) + implementation libs.paging.compose + implementation libs.lottie.compose implementation libs.maplibre implementation libs.maplibre.annotation diff --git a/app/schemas/org.wikipedia.database.AppDatabase/33.json b/app/schemas/org.wikipedia.database.AppDatabase/33.json new file mode 100644 index 00000000000..8fbfa51525e --- /dev/null +++ b/app/schemas/org.wikipedia.database.AppDatabase/33.json @@ -0,0 +1,876 @@ +{ + "formatVersion": 1, + "database": { + "version": 33, + "identityHash": "22530eedc11ebe27fdf14ab275b9ea28", + "entities": [ + { + "tableName": "HistoryEntry", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`authority` TEXT NOT NULL, `lang` TEXT NOT NULL, `apiTitle` TEXT NOT NULL, `displayTitle` TEXT NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `namespace` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `source` INTEGER NOT NULL, `prevId` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "authority", + "columnName": "authority", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lang", + "columnName": "lang", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "apiTitle", + "columnName": "apiTitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayTitle", + "columnName": "displayTitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "namespace", + "columnName": "namespace", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "prevId", + "columnName": "prevId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_HistoryEntry_lang_namespace_apiTitle", + "unique": false, + "columnNames": [ + "lang", + "namespace", + "apiTitle" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_HistoryEntry_lang_namespace_apiTitle` ON `${TABLE_NAME}` (`lang`, `namespace`, `apiTitle`)" + } + ] + }, + { + "tableName": "PageImage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`lang` TEXT NOT NULL, `namespace` TEXT NOT NULL, `apiTitle` TEXT NOT NULL, `imageName` TEXT, `description` TEXT, `timeSpentSec` INTEGER NOT NULL, `geoLat` REAL NOT NULL, `geoLon` REAL NOT NULL, PRIMARY KEY(`lang`, `namespace`, `apiTitle`))", + "fields": [ + { + "fieldPath": "lang", + "columnName": "lang", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "namespace", + "columnName": "namespace", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "apiTitle", + "columnName": "apiTitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imageName", + "columnName": "imageName", + "affinity": "TEXT" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "timeSpentSec", + "columnName": "timeSpentSec", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "geoLat", + "columnName": "geoLat", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "geoLon", + "columnName": "geoLon", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "lang", + "namespace", + "apiTitle" + ] + }, + "indices": [ + { + "name": "index_PageImage_lang_namespace_apiTitle", + "unique": false, + "columnNames": [ + "lang", + "namespace", + "apiTitle" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_PageImage_lang_namespace_apiTitle` ON `${TABLE_NAME}` (`lang`, `namespace`, `apiTitle`)" + } + ] + }, + { + "tableName": "RecentSearch", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`text` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`text`))", + "fields": [ + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "text" + ] + } + }, + { + "tableName": "TalkPageSeen", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sha` TEXT NOT NULL, PRIMARY KEY(`sha`))", + "fields": [ + { + "fieldPath": "sha", + "columnName": "sha", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sha" + ] + } + }, + { + "tableName": "EditSummary", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`summary` TEXT NOT NULL, `lastUsed` INTEGER NOT NULL, PRIMARY KEY(`summary`))", + "fields": [ + { + "fieldPath": "summary", + "columnName": "summary", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastUsed", + "columnName": "lastUsed", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "summary" + ] + } + }, + { + "tableName": "OfflineObject", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `url` TEXT NOT NULL, `lang` TEXT NOT NULL, `path` TEXT NOT NULL, `status` INTEGER NOT NULL, `usedByStr` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lang", + "columnName": "lang", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "usedByStr", + "columnName": "usedByStr", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "ReadingList", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`listTitle` TEXT NOT NULL, `description` TEXT, `mtime` INTEGER NOT NULL, `atime` INTEGER NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `sizeBytes` INTEGER NOT NULL, `dirty` INTEGER NOT NULL, `remoteId` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "listTitle", + "columnName": "listTitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "mtime", + "columnName": "mtime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "atime", + "columnName": "atime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sizeBytes", + "columnName": "sizeBytes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "dirty", + "columnName": "dirty", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteId", + "columnName": "remoteId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "ReadingListPage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`wiki` TEXT NOT NULL, `namespace` INTEGER NOT NULL, `displayTitle` TEXT NOT NULL, `apiTitle` TEXT NOT NULL, `description` TEXT, `thumbUrl` TEXT, `listId` INTEGER NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mtime` INTEGER NOT NULL, `atime` INTEGER NOT NULL, `offline` INTEGER NOT NULL, `status` INTEGER NOT NULL, `sizeBytes` INTEGER NOT NULL, `lang` TEXT NOT NULL, `revId` INTEGER NOT NULL, `remoteId` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "wiki", + "columnName": "wiki", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "namespace", + "columnName": "namespace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayTitle", + "columnName": "displayTitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "apiTitle", + "columnName": "apiTitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "thumbUrl", + "columnName": "thumbUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "listId", + "columnName": "listId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mtime", + "columnName": "mtime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "atime", + "columnName": "atime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "offline", + "columnName": "offline", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sizeBytes", + "columnName": "sizeBytes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lang", + "columnName": "lang", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "revId", + "columnName": "revId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteId", + "columnName": "remoteId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "Notification", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `wiki` TEXT NOT NULL, `read` TEXT, `category` TEXT NOT NULL, `type` TEXT NOT NULL, `revid` INTEGER NOT NULL, `title` TEXT, `agent` TEXT, `timestamp` TEXT, `contents` TEXT, PRIMARY KEY(`id`, `wiki`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wiki", + "columnName": "wiki", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "TEXT" + }, + { + "fieldPath": "category", + "columnName": "category", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "revid", + "columnName": "revid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT" + }, + { + "fieldPath": "agent", + "columnName": "agent", + "affinity": "TEXT" + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "TEXT" + }, + { + "fieldPath": "contents", + "columnName": "contents", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "wiki" + ] + } + }, + { + "tableName": "TalkTemplate", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `type` INTEGER NOT NULL, `order` INTEGER NOT NULL, `title` TEXT NOT NULL, `subject` TEXT NOT NULL, `message` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "Category", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`year` INTEGER NOT NULL, `month` INTEGER NOT NULL, `title` TEXT NOT NULL, `lang` TEXT NOT NULL, `count` INTEGER NOT NULL, PRIMARY KEY(`year`, `month`, `title`, `lang`))", + "fields": [ + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "month", + "columnName": "month", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lang", + "columnName": "lang", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "count", + "columnName": "count", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "year", + "month", + "title", + "lang" + ] + } + }, + { + "tableName": "DailyGameHistory", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `gameName` INTEGER NOT NULL, `language` TEXT NOT NULL, `year` INTEGER NOT NULL, `month` INTEGER NOT NULL, `day` INTEGER NOT NULL, `score` INTEGER NOT NULL, `playType` INTEGER NOT NULL, `gameData` TEXT, `status` INTEGER NOT NULL, `currentQuestionIndex` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "gameName", + "columnName": "gameName", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "month", + "columnName": "month", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "day", + "columnName": "day", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "score", + "columnName": "score", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playType", + "columnName": "playType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "gameData", + "columnName": "gameData", + "affinity": "TEXT" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "currentQuestionIndex", + "columnName": "currentQuestionIndex", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "RecommendedPage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `wiki` TEXT NOT NULL, `lang` TEXT NOT NULL, `namespace` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `apiTitle` TEXT NOT NULL, `displayTitle` TEXT NOT NULL, `description` TEXT, `thumbUrl` TEXT, `status` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wiki", + "columnName": "wiki", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lang", + "columnName": "lang", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "namespace", + "columnName": "namespace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "apiTitle", + "columnName": "apiTitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayTitle", + "columnName": "displayTitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "thumbUrl", + "columnName": "thumbUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "InterestTopic", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`topicId` TEXT NOT NULL, `lang` TEXT NOT NULL, `topicLabel` TEXT NOT NULL, `queryTopicId` TEXT NOT NULL, PRIMARY KEY(`topicId`, `lang`))", + "fields": [ + { + "fieldPath": "topicId", + "columnName": "topicId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lang", + "columnName": "lang", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "topicLabel", + "columnName": "topicLabel", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "queryTopicId", + "columnName": "queryTopicId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "topicId", + "lang" + ] + } + }, + { + "tableName": "InterestArticle", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`apiTitle` TEXT NOT NULL, `lang` TEXT NOT NULL, `namespace` INTEGER NOT NULL, `displayTitle` TEXT NOT NULL, `description` TEXT NOT NULL, `thumbUrl` TEXT NOT NULL, `topicId` TEXT, `topicLang` TEXT, PRIMARY KEY(`apiTitle`, `lang`, `namespace`), FOREIGN KEY(`topicId`, `topicLang`) REFERENCES `InterestTopic`(`topicId`, `lang`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "apiTitle", + "columnName": "apiTitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lang", + "columnName": "lang", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "namespace", + "columnName": "namespace", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayTitle", + "columnName": "displayTitle", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbUrl", + "columnName": "thumbUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "topicId", + "columnName": "topicId", + "affinity": "TEXT" + }, + { + "fieldPath": "topicLang", + "columnName": "topicLang", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "apiTitle", + "lang", + "namespace" + ] + }, + "indices": [ + { + "name": "index_InterestArticle_topicId_topicLang", + "unique": false, + "columnNames": [ + "topicId", + "topicLang" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_InterestArticle_topicId_topicLang` ON `${TABLE_NAME}` (`topicId`, `topicLang`)" + } + ], + "foreignKeys": [ + { + "table": "InterestTopic", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "topicId", + "topicLang" + ], + "referencedColumns": [ + "topicId", + "lang" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '22530eedc11ebe27fdf14ab275b9ea28')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/org/wikipedia/robots/feature/PageActionItemRobot.kt b/app/src/androidTest/java/org/wikipedia/robots/feature/PageActionItemRobot.kt index bbfa130d8a8..b71bbb8361e 100644 --- a/app/src/androidTest/java/org/wikipedia/robots/feature/PageActionItemRobot.kt +++ b/app/src/androidTest/java/org/wikipedia/robots/feature/PageActionItemRobot.kt @@ -66,7 +66,7 @@ class PageActionItemRobot : BaseRobot() { } fun clickExplore() = apply { - click.onViewWithId(R.id.page_explore) + click.onViewWithId(R.id.page_home) delay(TestConfig.DELAY_SHORT) } diff --git a/app/src/androidTest/java/org/wikipedia/robots/feature/SettingsRobot.kt b/app/src/androidTest/java/org/wikipedia/robots/feature/SettingsRobot.kt index f71c78d85b0..ab9001613c3 100644 --- a/app/src/androidTest/java/org/wikipedia/robots/feature/SettingsRobot.kt +++ b/app/src/androidTest/java/org/wikipedia/robots/feature/SettingsRobot.kt @@ -98,8 +98,8 @@ class SettingsRobot : BaseRobot() { delay(TestConfig.DELAY_SHORT) } - fun clickExploreFeed() = apply { - scrollToSettingsPreferenceItem(R.string.preference_title_customize_explore_feed, click()) + fun clickHomeFeed() = apply { + scrollToSettingsPreferenceItem(R.string.preference_title_customize_home_feed, click()) delay(TestConfig.DELAY_SHORT) } diff --git a/app/src/androidTest/java/org/wikipedia/robots/navigation/BottomNavRobot.kt b/app/src/androidTest/java/org/wikipedia/robots/navigation/BottomNavRobot.kt index bca7216826a..839775b7aa1 100644 --- a/app/src/androidTest/java/org/wikipedia/robots/navigation/BottomNavRobot.kt +++ b/app/src/androidTest/java/org/wikipedia/robots/navigation/BottomNavRobot.kt @@ -18,7 +18,7 @@ class BottomNavRobot : BaseRobot() { fun navigateToExploreFeed() = apply { onView( allOf( - withId(R.id.nav_tab_explore), withContentDescription("Explore"), + withId(R.id.nav_tab_home), withContentDescription("Home"), childAtPosition(childAtPosition(withId(R.id.main_nav_tab_layout), 0), 0), isDisplayed() ) ).perform(click()) diff --git a/app/src/androidTest/java/org/wikipedia/tests/settings/CustomizeExploreFeedTest.kt b/app/src/androidTest/java/org/wikipedia/tests/settings/CustomizeExploreFeedTest.kt index efc5c62b76b..196a2165f02 100644 --- a/app/src/androidTest/java/org/wikipedia/tests/settings/CustomizeExploreFeedTest.kt +++ b/app/src/androidTest/java/org/wikipedia/tests/settings/CustomizeExploreFeedTest.kt @@ -35,7 +35,7 @@ class CustomizeExploreFeedTest : BaseTest( .navigateToMoreMenu() .goToSettings() settingsRobot - .clickExploreFeed() + .clickHomeFeed() .openMoreOptionsToolbar() .hideAllExploreFeeds() .pressBack() @@ -46,7 +46,7 @@ class CustomizeExploreFeedTest : BaseTest( .navigateToMoreMenu() .goToSettings() settingsRobot - .clickExploreFeed() + .clickHomeFeed() .openMoreOptionsToolbar() .showAllExploreFeeds() .pressBack() diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b01011494a7..4a151e75e9e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -418,6 +418,12 @@ android:name=".donate.donationreminder.DonationReminderActivity" android:windowSoftInputMode="adjustResize"/> + + + + + + Unit = {}, +) { + WikiCard( + modifier = modifier + .fillMaxWidth(), + elevation = 0.dp, + border = BorderStroke(width = 1.dp, color = WikipediaTheme.colors.borderColor), + colors = CardDefaults.cardColors( + containerColor = if (isSelected) WikipediaTheme.colors.additionColor else WikipediaTheme.colors.paperColor + ), + shape = RoundedCornerShape(16.dp), + onClick = { + onItemClick(item) + } + ) { + Column( + modifier = Modifier + .fillMaxWidth() + ) { + if (!item.thumbUrl.isNullOrEmpty()) { + val request = ImageService.getRequest(LocalContext.current, url = item.thumbUrl, detectFace = true) + AsyncImage( + model = request, + placeholder = BrushPainter(SolidColor(WikipediaTheme.colors.borderColor)), + error = BrushPainter(SolidColor(WikipediaTheme.colors.borderColor)), + contentScale = ContentScale.Crop, + contentDescription = null, + modifier = Modifier + .fillMaxWidth() + .height(108.dp) + .clip(RoundedCornerShape(16.dp)) + ) + } + Column( + modifier = Modifier.padding(8.dp) + ) { + HtmlText( + text = item.displayText, + style = MaterialTheme.typography.bodyLarge, + color = WikipediaTheme.colors.primaryColor + ) + Spacer(modifier = Modifier.height(2.dp)) + Row { + if (!item.description.isNullOrEmpty()) { + HtmlText( + text = item.description.orEmpty(), + style = MaterialTheme.typography.bodyMedium, + color = WikipediaTheme.colors.secondaryColor, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + } else { + Spacer(modifier = Modifier.weight(1f)) + } + if (isSelected) { + Spacer(modifier = Modifier.width(8.dp)) + Icon( + modifier = Modifier.size(24.dp).align(Alignment.Bottom), + painter = painterResource(R.drawable.check_circle_24px), + tint = WikipediaTheme.colors.primaryColor, + contentDescription = null + ) + } else { + Spacer(modifier = Modifier.width(32.dp).height(24.dp)) + } + } + } + } + } +} diff --git a/app/src/main/java/org/wikipedia/compose/components/HtmlText.kt b/app/src/main/java/org/wikipedia/compose/components/HtmlText.kt index 58e70d895c0..e91cd0d4c89 100644 --- a/app/src/main/java/org/wikipedia/compose/components/HtmlText.kt +++ b/app/src/main/java/org/wikipedia/compose/components/HtmlText.kt @@ -1,5 +1,6 @@ package org.wikipedia.compose.components +import androidx.compose.foundation.text.TextAutoSize import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -44,7 +45,8 @@ fun HtmlText( overflow: TextOverflow = TextOverflow.Ellipsis, lineHeight: TextUnit = 1.6.em, linkInteractionListener: LinkInteractionListener = defaultLinkInteractionListener(), - textAlign: TextAlign = TextAlign.Start + textAlign: TextAlign = TextAlign.Start, + autoSize: TextAutoSize? = null ) { Text( modifier = modifier, @@ -58,7 +60,8 @@ fun HtmlText( color = color, maxLines = maxLines, overflow = overflow, - textAlign = textAlign + textAlign = textAlign, + autoSize = autoSize ) } diff --git a/app/src/main/java/org/wikipedia/compose/components/SearchBarCard.kt b/app/src/main/java/org/wikipedia/compose/components/SearchBarCard.kt new file mode 100644 index 00000000000..849f9a22f1b --- /dev/null +++ b/app/src/main/java/org/wikipedia/compose/components/SearchBarCard.kt @@ -0,0 +1,58 @@ +package org.wikipedia.compose.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import org.wikipedia.R +import org.wikipedia.compose.theme.WikipediaTheme + +@Composable +fun SearchBarCard( + modifier: Modifier = Modifier, + text: String, + onSearchClick: () -> Unit +) { + Row( + modifier = modifier + .fillMaxWidth() + .height(56.dp) + .clip(RoundedCornerShape(28.dp)) + .background( + color = WikipediaTheme.colors.backgroundColor, + shape = RoundedCornerShape(24.dp) + ) + .clickable(onClick = onSearchClick), + verticalAlignment = Alignment.CenterVertically + ) { + Spacer(modifier = Modifier.width(16.dp)) + Icon( + painter = painterResource(R.drawable.outline_search_24), + contentDescription = stringResource(R.string.search_hint), + tint = WikipediaTheme.colors.secondaryColor, + modifier = Modifier.size(24.dp) + ) + Text( + modifier = Modifier.padding(start = 16.dp, end = 16.dp), + text = text, + style = MaterialTheme.typography.bodyLarge, + color = WikipediaTheme.colors.primaryColor + ) + } +} diff --git a/app/src/main/java/org/wikipedia/compose/components/WikiLangCodeBox.kt b/app/src/main/java/org/wikipedia/compose/components/WikiLangCodeBox.kt new file mode 100644 index 00000000000..126cbc0b91f --- /dev/null +++ b/app/src/main/java/org/wikipedia/compose/components/WikiLangCodeBox.kt @@ -0,0 +1,79 @@ +package org.wikipedia.compose.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.wikipedia.compose.theme.BaseTheme +import org.wikipedia.compose.theme.WikipediaTheme +import org.wikipedia.theme.Theme + +@Composable +fun WikiLangCodeBox( + modifier: Modifier = Modifier, + languageCode: String, + backgroundColor: Color = WikipediaTheme.colors.paperColor, + borderColor: Color = WikipediaTheme.colors.primaryColor, + shape: RoundedCornerShape = RoundedCornerShape(4.dp), + textColor: Color = WikipediaTheme.colors.primaryColor, + fontSize: TextUnit = 10.sp, +) { + Box( + modifier = modifier + .background(color = backgroundColor, shape = shape) + .border(width = 1.5.dp, color = borderColor, shape = shape), + contentAlignment = Alignment.Center + ) { + Text( + modifier = Modifier.padding(horizontal = 4.dp), + text = languageCode.uppercase(), + fontSize = fontSize, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + color = textColor + ) + } +} + +@Preview +@Composable +private fun WikiLangCodeBoxRegularPreview() { + BaseTheme(currentTheme = Theme.LIGHT) { + WikiLangCodeBox( + modifier = Modifier + .height(20.dp) + .widthIn(min = 20.dp), + languageCode = "en" + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun WikiLangCodeBoxDarkPreview() { + BaseTheme(currentTheme = Theme.LIGHT) { + WikiLangCodeBox( + modifier = Modifier + .height(20.dp) + .widthIn(min = 20.dp), + languageCode = "en", + backgroundColor = WikipediaTheme.colors.primaryColor, + borderColor = WikipediaTheme.colors.primaryColor, + textColor = WikipediaTheme.colors.paperColor + ) + } +} diff --git a/app/src/main/java/org/wikipedia/compose/extensions/Modifier.kt b/app/src/main/java/org/wikipedia/compose/extensions/Modifier.kt index d451aa7e455..0259af4076f 100644 --- a/app/src/main/java/org/wikipedia/compose/extensions/Modifier.kt +++ b/app/src/main/java/org/wikipedia/compose/extensions/Modifier.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -25,13 +26,17 @@ import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.composed +import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.draw.scale +import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import org.wikipedia.compose.components.WikiTopAppBar @@ -88,6 +93,65 @@ fun Modifier.noRippleClickable( onClick = onClick ) +fun Modifier.shimmerEffect( + shimmerColors: List? = null, + durationMs: Int = 1200, + easing: Easing = LinearEasing, + heightMultiplier: Float = 1f, + transition: InfiniteTransition, +): Modifier = composed { + val colors = shimmerColors ?: WikipediaTheme.colors.shimmerColors() + var size by remember { mutableStateOf(IntSize.Zero) } + + val startOffsetX by transition.animateFloat( + initialValue = -2 * size.width.toFloat(), + targetValue = 2 * size.width.toFloat(), + animationSpec = infiniteRepeatable( + animation = tween(durationMs, easing = easing), + repeatMode = RepeatMode.Restart + ) + ) + + val brush = Brush.linearGradient( + colors = colors, + start = Offset(startOffsetX, 0f), + end = Offset(startOffsetX + size.width.toFloat(), size.height.toFloat() * heightMultiplier) + ) + + this + .onSizeChanged { size = it } + .background(brush) +} + +fun Modifier.lazyColumnScrollbar( + state: LazyListState, + color: Color, + thumbHeight: Dp = 48.dp +): Modifier = drawWithContent { + drawContent() + val info = state.layoutInfo + val visibleItems = info.visibleItemsInfo + if (visibleItems.isNotEmpty() && (state.canScrollForward || state.canScrollBackward)) { + val viewportHeight = size.height + val fixedThumbHeight = thumbHeight.toPx() + + val firstItem = visibleItems.first() + val itemSize = firstItem.size.coerceAtLeast(1) + val estimatedTotalHeight = itemSize * info.totalItemsCount + val currentOffset = firstItem.index * itemSize - firstItem.offset + val maxOffset = (estimatedTotalHeight - viewportHeight).coerceAtLeast(1f) + val scrollFraction = (currentOffset.toFloat() / maxOffset).coerceIn(0f, 1f) + val thumbOffset = scrollFraction * (viewportHeight - fixedThumbHeight) + + drawRoundRect( + color = color, + topLeft = Offset(size.width - 4.dp.toPx(), thumbOffset), + size = Size(4.dp.toPx(), fixedThumbHeight), + cornerRadius = CornerRadius(2.dp.toPx()) + ) + } +} + @Preview @Composable private fun PreviewPulse() { @@ -118,33 +182,3 @@ private fun PreviewPulse() { } } } - -fun Modifier.shimmerEffect( - shimmerColors: List? = null, - durationMs: Int = 1200, - easing: Easing = LinearEasing, - heightMultiplier: Float = 1f, - transition: InfiniteTransition, -): Modifier = composed { - val colors = shimmerColors ?: WikipediaTheme.colors.shimmerColors() - var size by remember { mutableStateOf(IntSize.Zero) } - - val startOffsetX by transition.animateFloat( - initialValue = -2 * size.width.toFloat(), - targetValue = 2 * size.width.toFloat(), - animationSpec = infiniteRepeatable( - animation = tween(durationMs, easing = easing), - repeatMode = RepeatMode.Restart - ) - ) - - val brush = Brush.linearGradient( - colors = colors, - start = Offset(startOffsetX, 0f), - end = Offset(startOffsetX + size.width.toFloat(), size.height.toFloat() * heightMultiplier) - ) - - this - .onSizeChanged { size = it } - .background(brush) -} diff --git a/app/src/main/java/org/wikipedia/compose/theme/WikipediaTheme.kt b/app/src/main/java/org/wikipedia/compose/theme/WikipediaTheme.kt index 6756e854c77..bbfa2c17769 100644 --- a/app/src/main/java/org/wikipedia/compose/theme/WikipediaTheme.kt +++ b/app/src/main/java/org/wikipedia/compose/theme/WikipediaTheme.kt @@ -7,9 +7,6 @@ import androidx.compose.material3.RippleConfiguration import androidx.compose.material3.ripple import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import org.wikipedia.WikipediaApp import org.wikipedia.theme.Theme @@ -19,9 +16,7 @@ fun BaseTheme( currentTheme: Theme = WikipediaApp.instance.currentTheme, content: @Composable () -> Unit ) { - val appTheme by remember { mutableStateOf(currentTheme) } - - val wikipediaColorSystem = when (appTheme) { + val wikipediaColorSystem = when (currentTheme) { Theme.LIGHT -> LightColors Theme.DARK -> DarkColors Theme.BLACK -> BlackColors diff --git a/app/src/main/java/org/wikipedia/database/AppDatabase.kt b/app/src/main/java/org/wikipedia/database/AppDatabase.kt index 5eac0d10b1c..3f6e943a1b1 100644 --- a/app/src/main/java/org/wikipedia/database/AppDatabase.kt +++ b/app/src/main/java/org/wikipedia/database/AppDatabase.kt @@ -11,6 +11,10 @@ import org.wikipedia.categories.db.Category import org.wikipedia.categories.db.CategoryDao import org.wikipedia.edit.db.EditSummary import org.wikipedia.edit.db.EditSummaryDao +import org.wikipedia.feed.personalization.db.dao.InterestArticleDao +import org.wikipedia.feed.personalization.db.dao.InterestTopicDao +import org.wikipedia.feed.personalization.db.entity.InterestArticle +import org.wikipedia.feed.personalization.db.entity.InterestTopic import org.wikipedia.games.db.DailyGameHistory import org.wikipedia.games.db.DailyGameHistoryDao import org.wikipedia.games.onthisday.OnThisDayGameViewModel @@ -39,7 +43,7 @@ import org.wikipedia.talk.db.TalkTemplateDao import java.time.LocalDate const val DATABASE_NAME = "wikipedia.db" -const val DATABASE_VERSION = 32 +const val DATABASE_VERSION = 33 @Database( entities = [ @@ -55,7 +59,9 @@ const val DATABASE_VERSION = 32 TalkTemplate::class, Category::class, DailyGameHistory::class, - RecommendedPage::class + RecommendedPage::class, + InterestTopic::class, + InterestArticle::class ], version = DATABASE_VERSION ) @@ -81,6 +87,8 @@ abstract class AppDatabase : RoomDatabase() { abstract fun categoryDao(): CategoryDao abstract fun dailyGameHistoryDao(): DailyGameHistoryDao abstract fun recommendedPageDao(): RecommendedPageDao + abstract fun topicInterestDao(): InterestTopicDao + abstract fun articleInterestDao(): InterestArticleDao companion object { val MIGRATION_19_20 = object : Migration(19, 20) { @@ -355,13 +363,37 @@ abstract class AppDatabase : RoomDatabase() { db.execSQL("ALTER TABLE DailyGameHistory ADD COLUMN currentQuestionIndex INTEGER NOT NULL DEFAULT ${OnThisDayGameViewModel.MAX_QUESTIONS}") } } + val MIGRATION_32_33 = object : Migration(32, 33) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("CREATE TABLE IF NOT EXISTS InterestTopic (" + + "topicId TEXT NOT NULL," + + "lang TEXT NOT NULL," + + "topicLabel TEXT NOT NULL," + + "queryTopicId TEXT NOT NULL," + + "PRIMARY KEY (topicId, lang)" + + ")") + db.execSQL("CREATE TABLE IF NOT EXISTS InterestArticle (" + + "apiTitle TEXT NOT NULL," + + "lang TEXT NOT NULL," + + "namespace INTEGER NOT NULL," + + "displayTitle TEXT NOT NULL," + + "description TEXT NOT NULL," + + "thumbUrl TEXT NOT NULL," + + "topicId TEXT," + + "topicLang TEXT," + + "PRIMARY KEY (apiTitle, lang, namespace)," + + "FOREIGN KEY(topicId, topicLang) REFERENCES InterestTopic(topicId, lang) ON DELETE SET NULL" + + ")") + db.execSQL("CREATE INDEX IF NOT EXISTS index_InterestArticle_topicId_topicLang ON InterestArticle (topicId, topicLang)") + } + } val instance: AppDatabase by lazy(LazyThreadSafetyMode.SYNCHRONIZED) { Room.databaseBuilder(WikipediaApp.instance, AppDatabase::class.java, DATABASE_NAME) .addMigrations(MIGRATION_19_20, MIGRATION_20_21, MIGRATION_21_22, MIGRATION_22_23, MIGRATION_23_24, MIGRATION_24_25, MIGRATION_25_26, MIGRATION_26_27, MIGRATION_26_28, MIGRATION_27_28, MIGRATION_28_29, MIGRATION_29_30, - MIGRATION_30_31, MIGRATION_31_32) + MIGRATION_30_31, MIGRATION_31_32, MIGRATION_32_33) .fallbackToDestructiveMigration(false) .build() } diff --git a/app/src/main/java/org/wikipedia/dataclient/Service.kt b/app/src/main/java/org/wikipedia/dataclient/Service.kt index 2864cab1180..3329165cada 100644 --- a/app/src/main/java/org/wikipedia/dataclient/Service.kt +++ b/app/src/main/java/org/wikipedia/dataclient/Service.kt @@ -742,6 +742,13 @@ interface Service { @GET(MW_API_PREFIX + "action=query&prop=info&converttitles=&inprop=varianttitles") suspend fun getVariantTitlesByTitles(@Query("titles") titles: String): MwQueryResponse + @GET(MW_API_PREFIX + "action=query&generator=search&redirects=&converttitles=&prop=description|pageimages|info&piprop=thumbnail" + + "&pilicense=any&gpsnamespace=0&inprop=varianttitles|displaytitle&pithumbsize=" + PREFERRED_THUMB_SIZE) + suspend fun getArticlesByTopic( + @Query("gsrsearch") articleTopics: String, + @Query("gsrlimit") limit: Int + ): MwQueryResponse + companion object { const val WIKIPEDIA_URL = "https://${WikiSite.BASE_DOMAIN}/" const val BASE_AUTHORITY_WIKIMEDIA = "wikimedia.org" diff --git a/app/src/main/java/org/wikipedia/dataclient/mwapi/MwQueryResult.kt b/app/src/main/java/org/wikipedia/dataclient/mwapi/MwQueryResult.kt index 59e3791c245..ef936207d97 100644 --- a/app/src/main/java/org/wikipedia/dataclient/mwapi/MwQueryResult.kt +++ b/app/src/main/java/org/wikipedia/dataclient/mwapi/MwQueryResult.kt @@ -264,6 +264,7 @@ class MwQueryResult { class Message { val name: String = "" val content: String = "" + val missing: Boolean = false } @Serializable diff --git a/app/src/main/java/org/wikipedia/dataclient/page/PageSummary.kt b/app/src/main/java/org/wikipedia/dataclient/page/PageSummary.kt index c1d1b559433..533781f3fa2 100644 --- a/app/src/main/java/org/wikipedia/dataclient/page/PageSummary.kt +++ b/app/src/main/java/org/wikipedia/dataclient/page/PageSummary.kt @@ -6,6 +6,7 @@ import kotlinx.parcelize.Parcelize import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import org.wikipedia.dataclient.WikiSite +import org.wikipedia.history.HistoryEntry import org.wikipedia.json.LocationSerializer import org.wikipedia.page.Namespace import org.wikipedia.page.Page @@ -72,6 +73,10 @@ open class PageSummary( return PageTitle(apiTitle, wiki, thumbnailUrl, description, displayTitle, extract) } + fun getHistoryEntry(wiki: WikiSite, source: Int): HistoryEntry { + return HistoryEntry(getPageTitle(wiki), source) + } + override fun toString(): String { return displayTitle } diff --git a/app/src/main/java/org/wikipedia/feed/CommunityModuleHeader.kt b/app/src/main/java/org/wikipedia/feed/CommunityModuleHeader.kt new file mode 100644 index 00000000000..d828f1b9e81 --- /dev/null +++ b/app/src/main/java/org/wikipedia/feed/CommunityModuleHeader.kt @@ -0,0 +1,95 @@ +package org.wikipedia.feed + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.wikipedia.R +import org.wikipedia.compose.theme.BaseTheme +import org.wikipedia.compose.theme.WikipediaTheme +import org.wikipedia.dataclient.WikiSite +import org.wikipedia.extensions.getString +import org.wikipedia.theme.Theme + +@Composable +fun CommunityModuleHeader( + modifier: Modifier = Modifier, + wikiSite: WikiSite, + @StringRes titleResId: Int, + @StringRes subTitleResId: Int, + @DrawableRes contextIconResId: Int? = null, + onOverflowClick: () -> Unit = {}, +) { + val context = LocalContext.current + Column( + modifier = modifier + ) { + Row( + modifier = Modifier.padding(top = 8.dp).fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + modifier = Modifier.weight(1f).padding(horizontal = 16.dp), + text = context.getString(wikiSite.languageCode, titleResId), + color = WikipediaTheme.colors.primaryColor, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.W500 + ) + contextIconResId?.let { + Icon( + painter = painterResource(it), + contentDescription = null, + tint = WikipediaTheme.colors.primaryColor, + modifier = Modifier.size(24.dp) + ) + } + IconButton( + modifier = Modifier.size(48.dp), + onClick = { + onOverflowClick() + } + ) { + Icon( + painter = painterResource(R.drawable.ic_more_vert_white_24dp), + contentDescription = context.getString(wikiSite.languageCode, R.string.menu_feed_overflow_label), + tint = WikipediaTheme.colors.primaryColor, + modifier = Modifier.size(24.dp) + ) + } + } + Text( + modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp), + text = context.getString(wikiSite.languageCode, subTitleResId), + color = WikipediaTheme.colors.secondaryColor, + style = MaterialTheme.typography.bodyMedium + ) + } +} + +@Preview +@Composable +fun CommunityModuleHeaderPreview() { + BaseTheme(currentTheme = Theme.LIGHT) { + CommunityModuleHeader( + wikiSite = WikiSite.preview(), + titleResId = R.string.view_featured_image_card_title, + subTitleResId = R.string.explore_feed_potd_subtitle, + contextIconResId = R.drawable.ic_commons_logo + ) + } +} diff --git a/app/src/main/java/org/wikipedia/feed/FeedFragment.kt b/app/src/main/java/org/wikipedia/feed/FeedFragment.kt index 43fea5a46a0..57aced1c532 100644 --- a/app/src/main/java/org/wikipedia/feed/FeedFragment.kt +++ b/app/src/main/java/org/wikipedia/feed/FeedFragment.kt @@ -28,6 +28,7 @@ import org.wikipedia.feed.image.FeaturedImageCard import org.wikipedia.feed.model.Card import org.wikipedia.feed.model.WikiSiteCard import org.wikipedia.feed.news.NewsCard +import org.wikipedia.feed.news.NewsItem import org.wikipedia.feed.news.NewsItemView import org.wikipedia.feed.random.RandomCardView import org.wikipedia.feed.topread.TopReadArticlesActivity @@ -74,11 +75,11 @@ class FeedFragment : Fragment() { fun onFeedSelectPageWithAnimation(entry: HistoryEntry, sharedElements: Array>) fun onFeedAddPageToList(entry: HistoryEntry, addToDefault: Boolean) fun onFeedMovePageToList(sourceReadingListId: Long, entry: HistoryEntry) - fun onFeedNewsItemSelected(card: NewsCard, view: NewsItemView) + fun onFeedNewsItemSelected(newsItem: NewsItem, wikiSite: WikiSite) fun onFeedSeCardFooterClicked() - fun onFeedShareImage(card: FeaturedImageCard) + fun onFeedShareImage(image: FeaturedImage, age: Int) fun onFeedDownloadImage(image: FeaturedImage) - fun onFeaturedImageSelected(card: FeaturedImageCard) + fun onFeaturedImageSelected(image: FeaturedImage) fun onLoginRequested() fun updateToolbarElevation(elevate: Boolean) fun onWikiGamesCardFooterClicked() @@ -266,11 +267,13 @@ class FeedFragment : Fragment() { } override fun onNewsItemSelected(card: NewsCard, view: NewsItemView) { - callback?.onFeedNewsItemSelected(card, view) + view.newsItem?.let { + callback?.onFeedNewsItemSelected(it, card.wikiSite()) + } } override fun onShareImage(card: FeaturedImageCard) { - callback?.onFeedShareImage(card) + callback?.onFeedShareImage(card.baseImage(), card.age()) } override fun onDownloadImage(image: FeaturedImage) { @@ -278,7 +281,7 @@ class FeedFragment : Fragment() { } override fun onFeaturedImageSelected(card: FeaturedImageCard) { - callback?.onFeaturedImageSelected(card) + callback?.onFeaturedImageSelected(card.baseImage()) } override fun onAnnouncementPositiveAction(card: Card, uri: Uri) { diff --git a/app/src/main/java/org/wikipedia/feed/HomeFragment.kt b/app/src/main/java/org/wikipedia/feed/HomeFragment.kt new file mode 100644 index 00000000000..ddf8bc5374b --- /dev/null +++ b/app/src/main/java/org/wikipedia/feed/HomeFragment.kt @@ -0,0 +1,951 @@ +package org.wikipedia.feed + +import android.os.Bundle +import android.text.format.DateFormat +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.activity.compose.LocalActivity +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +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.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults.Indicator +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState +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.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.painter.ColorPainter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLocale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import coil3.compose.AsyncImage +import org.wikipedia.Constants +import org.wikipedia.Constants.InvokeSource +import org.wikipedia.R +import org.wikipedia.WikipediaApp +import org.wikipedia.compose.components.AppButton +import org.wikipedia.compose.components.WikiLangCodeBox +import org.wikipedia.compose.components.error.WikiErrorClickEvents +import org.wikipedia.compose.components.error.WikiErrorView +import org.wikipedia.compose.theme.BaseTheme +import org.wikipedia.compose.theme.WikipediaTheme +import org.wikipedia.dataclient.WikiSite +import org.wikipedia.extensions.getString +import org.wikipedia.feed.featured.FeaturedArticleModule +import org.wikipedia.feed.image.FeaturedImage +import org.wikipedia.feed.image.FeaturedImageModule +import org.wikipedia.feed.news.NewsItem +import org.wikipedia.feed.news.NewsModule +import org.wikipedia.feed.onboarding.ExploreFeedUpdatePromptActivity +import org.wikipedia.feed.onthisday.OnThisDayActivity +import org.wikipedia.feed.onthisday.OnThisDayModule +import org.wikipedia.feed.topread.TopReadArticlesActivity +import org.wikipedia.feed.topread.TopReadListCard +import org.wikipedia.feed.topread.TopReadModule +import org.wikipedia.history.HistoryEntry +import org.wikipedia.language.AppLanguageState +import org.wikipedia.main.MainActivity +import org.wikipedia.main.MainFragment +import org.wikipedia.navtab.NavTab +import org.wikipedia.settings.Prefs +import org.wikipedia.settings.languages.WikipediaLanguagesActivity +import org.wikipedia.theme.Theme +import org.wikipedia.util.DimenUtil +import org.wikipedia.util.ShareUtil +import org.wikipedia.views.imageservice.ImageService +import java.time.LocalDate +import java.time.format.DateTimeFormatter + +class HomeFragment : Fragment() { + private val viewModel: HomeViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (savedInstanceState == null) { + maybeShowExploreFeedUpdatePrompt() + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return ComposeView(requireActivity()).apply { + setContent { + val selectedTab by viewModel.selectedTab.collectAsState() + val wikiSite by viewModel.wikiSite.collectAsState() + + BaseTheme(currentTheme = if (selectedTab == HomeTab.FOR_YOU) Theme.BLACK else WikipediaApp.instance.currentTheme) { + HomeScreen( + wikiSite = wikiSite, + languageState = WikipediaApp.instance.languageState, + selectedTab = selectedTab, + communityContentState = viewModel.communityState.collectAsState().value, + forYouContentState = viewModel.forYouState.collectAsState().value, + onSelectTab = { + viewModel.selectTab(it) + (requireActivity() as? MainActivity)?.onTabChanged(NavTab.HOME) + }, + onRefreshTab = { + if (it == HomeTab.COMMUNITY) { + viewModel.refreshCommunityContent() + } else { + viewModel.refreshForYouContent() + } + }, + onLoadMoreCommunityContent = viewModel::loadCommunityContent, + onLoadMoreForYouContent = viewModel::loadForYouContent, + onPageClick = { + (parentFragment as? MainFragment)?.onFeedSelectPage(it, false) + }, + onPageBookmarkClick = { + (parentFragment as? MainFragment)?.onFeedAddPageToList(it, false) + }, + onPageShareClick = { + ShareUtil.shareText(requireContext(), it.title) + }, + onNewsClick = { newsItem -> + (parentFragment as? MainFragment)?.onFeedNewsItemSelected(newsItem, wikiSite) + }, + onImageClick = { + (parentFragment as? MainFragment)?.onFeaturedImageSelected(it) + }, + onImageShareClick = { image, age -> + (parentFragment as? MainFragment)?.onFeedShareImage(image, age) + }, + onImageDownloadClick = { + (parentFragment as? MainFragment)?.onFeedDownloadImage(it) + }, + onLanguageSelected = { languageCode -> + viewModel.updateLanguage(languageCode) + }, + onManageLanguagesClick = { + requireActivity().startActivity(WikipediaLanguagesActivity.newIntent(requireContext(), invokeSource = Constants.InvokeSource.FEED)) + } + ) + } + } + } + } + + fun getCurrentTab(): HomeTab { + return viewModel.selectedTab.value + } + + private fun maybeShowExploreFeedUpdatePrompt() { + if (!Prefs.isInitialOnboardingEnabled && Prefs.isExploreFeedUpdatePromptShown.not()) { + startActivity(ExploreFeedUpdatePromptActivity.newIntent(requireContext())) + } + } +} + +@Composable +fun HomeScreen( + wikiSite: WikiSite, + languageState: AppLanguageState? = null, + selectedTab: HomeTab, + communityContentState: CommunityContentState, + forYouContentState: ForYouContentState, + onSelectTab: (HomeTab) -> Unit = {}, + onRefreshTab: (HomeTab) -> Unit = {}, + onLoadMoreCommunityContent: () -> Unit = {}, + onLoadMoreForYouContent: () -> Unit = {}, + onPageClick: (historyEntry: HistoryEntry) -> Unit = {}, + onPageBookmarkClick: (historyEntry: HistoryEntry) -> Unit = {}, + onPageShareClick: (historyEntry: HistoryEntry) -> Unit = {}, + onNewsClick: (newsItem: NewsItem) -> Unit = {}, + onImageClick: (image: FeaturedImage) -> Unit = {}, + onImageDownloadClick: (image: FeaturedImage) -> Unit = {}, + onImageShareClick: (image: FeaturedImage, age: Int) -> Unit = { _, _ -> }, + onLanguageSelected: (String) -> Unit = {}, + onManageLanguagesClick: () -> Unit = {} +) { + val context = LocalContext.current + val topInset = if (context is MainActivity) { + DimenUtil.roundedPxToDp((context.getStatusBarInsets()?.top ?: 0).toFloat()) + } else 64 + val pullToRefreshState = rememberPullToRefreshState() + val isRefreshing = pullToRefreshState.isAnimating && (communityContentState.isInitialLoading || forYouContentState.isInitialLoading) + + PullToRefreshBox( + modifier = Modifier.fillMaxSize(), + state = pullToRefreshState, + isRefreshing = isRefreshing, + onRefresh = { onRefreshTab(selectedTab) }, + indicator = { + Indicator( + modifier = Modifier.align(Alignment.TopCenter), + isRefreshing = isRefreshing, + containerColor = WikipediaTheme.colors.paperColor, + color = WikipediaTheme.colors.progressiveColor, + state = pullToRefreshState + ) + } + ) { + Box( + modifier = Modifier.fillMaxSize() + ) { + when (selectedTab) { + HomeTab.COMMUNITY -> { + Column( + modifier = Modifier + .align(Alignment.TopCenter) + .fillMaxSize() + .background(WikipediaTheme.colors.paperColor) + ) { + Image( + painter = painterResource(R.drawable.feed_header_wordmark), + contentDescription = null, + colorFilter = ColorFilter.tint(WikipediaTheme.colors.primaryColor), + contentScale = ContentScale.FillWidth, + modifier = Modifier + .statusBarsPadding() + .padding(start = 20.dp, top = (topInset + 16).dp) + .width(128.dp) + ) + + // Tab selector + HomeTabBar( + modifier = Modifier.padding(top = 8.dp), + wikiSite = wikiSite, + selectedTab = selectedTab, + languageState = languageState, + onTabSelected = onSelectTab, + onLanguageSelected = { + onLanguageSelected(it) + }, + onManageLanguagesClick = { + onManageLanguagesClick() + } + ) + + CommunityContentTab( + modifier = Modifier.weight(1f), + wikiSite = wikiSite, + state = communityContentState, + onLoadMore = onLoadMoreCommunityContent, + onPageClick = onPageClick, + onPageBookmarkClick = onPageBookmarkClick, + onPageShareClick = onPageShareClick, + onNewsClick = onNewsClick, + onImageClick = onImageClick, + onImageDownloadClick = onImageDownloadClick, + onImageShareClick = onImageShareClick + ) + } + } + + HomeTab.FOR_YOU -> { + ForYouContentTab( + state = forYouContentState, + wikiSite = wikiSite, + onLoadMore = onLoadMoreForYouContent + ) + + // Floating toolbar with gradient scrim, wordmark, and tab selector. + Column( + modifier = Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + .background( + Brush.verticalGradient( + colorStops = arrayOf( + 0.0f to Color.Black.copy(alpha = 0.78f), + 0.18f to Color.Black.copy(alpha = 0.64f), + 0.38f to Color.Black.copy(alpha = 0.40f), + 0.58f to Color.Black.copy(alpha = 0.20f), + 0.76f to Color.Black.copy(alpha = 0.08f), + 0.90f to Color.Black.copy(alpha = 0.02f), + 1.0f to Color.Transparent + ) + ) + ) + ) { + Image( + painter = painterResource(R.drawable.feed_header_wordmark), + contentDescription = null, + colorFilter = ColorFilter.tint(WikipediaTheme.colors.primaryColor), + contentScale = ContentScale.FillWidth, + modifier = Modifier + .statusBarsPadding() + .padding(start = 20.dp, top = (topInset + 16).dp) + .width(128.dp) + ) + + // Tab selector + HomeTabBar( + modifier = Modifier.padding(top = 8.dp, bottom = 32.dp), + wikiSite = wikiSite, + selectedTab = selectedTab, + languageState = languageState, + onTabSelected = onSelectTab, + onLanguageSelected = { + onLanguageSelected(it) + }, + onManageLanguagesClick = { + onManageLanguagesClick() + } + ) + } + } + } + } + } +} + +@Composable +fun HomeTabBar( + modifier: Modifier, + wikiSite: WikiSite, + selectedTab: HomeTab, + languageState: AppLanguageState? = null, + onTabSelected: (HomeTab) -> Unit, + onLanguageSelected: (String) -> Unit, + onManageLanguagesClick: () -> Unit +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + modifier = Modifier.weight(1f) + ) { + HomeTab.entries.forEach { tab -> + val isSelected = tab == selectedTab + val label = when (tab) { + HomeTab.COMMUNITY -> LocalContext.current.getString(wikiSite.languageCode, R.string.explore_feed_community_tab_label) + HomeTab.FOR_YOU -> LocalContext.current.getString(wikiSite.languageCode, R.string.explore_feed_for_you_tab_label) + } + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .width(IntrinsicSize.Max) + .clickable { onTabSelected(tab) } + .padding(horizontal = 12.dp, vertical = 8.dp) + ) { + Text( + text = label, + color = if (selectedTab == HomeTab.FOR_YOU) WikipediaTheme.colors.primaryColor else if (isSelected) WikipediaTheme.colors.progressiveColor else WikipediaTheme.colors.primaryColor, + style = MaterialTheme.typography.titleSmall + ) + Spacer(modifier = Modifier.height(4.dp)) + Box( + modifier = Modifier + .fillMaxWidth() + .height(3.dp) + .clip(RoundedCornerShape(1.5.dp)) + .background( + if (isSelected) { + if (selectedTab == HomeTab.FOR_YOU) WikipediaTheme.colors.primaryColor + else WikipediaTheme.colors.progressiveColor + } else Color.Transparent + ) + ) + } + } + } + LanguageDropDownMenu( + selectedLanguageCode = wikiSite.languageCode, + onLanguageSelected = { onLanguageSelected(it) }, + onManageLanguagesClick = { onManageLanguagesClick() }, + languageState = languageState + ) + } +} + +@Composable +fun CommunityContentTab( + modifier: Modifier = Modifier, + wikiSite: WikiSite, + state: CommunityContentState, + onLoadMore: () -> Unit, + onPageClick: (historyEntry: HistoryEntry) -> Unit, + onPageBookmarkClick: (historyEntry: HistoryEntry) -> Unit = {}, + onPageShareClick: (historyEntry: HistoryEntry) -> Unit = {}, + onNewsClick: (newsItem: NewsItem) -> Unit = {}, + onImageClick: (image: FeaturedImage) -> Unit = {}, + onImageDownloadClick: (image: FeaturedImage) -> Unit = {}, + onImageShareClick: (image: FeaturedImage, age: Int) -> Unit = { _, _ -> } +) { + val activity = LocalActivity.current as? MainActivity + when { + state.isInitialLoading -> { + LoadingIndicator(modifier = modifier.fillMaxHeight()) + } + state.error != null && state.days.isEmpty() -> { + ErrorState(state.error, onRetry = onLoadMore) + } + else -> { + LazyColumn( + modifier = modifier.fillMaxSize(), + contentPadding = PaddingValues(top = 16.dp, bottom = 16.dp) + ) { + item { + CommunityDisclaimer( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + wikiSite = wikiSite + ) + Spacer(modifier = Modifier.height(16.dp)) + } + state.days.forEach { day -> + + item(key = "day-header-${day.age}") { + DayHeader(day.date, isFirst = day.age == 0) + } + + day.featuredArticle?.let { article -> + item(key = "tfa-${day.age}") { + FeaturedArticleModule( + wikiSite = wikiSite, + article, + onPageClick = { + onPageClick(it.getHistoryEntry(wikiSite, HistoryEntry.SOURCE_FEED_FEATURED)) + }, + onOverflowClick = { + // TODO + }, + onShareClick = { + onPageShareClick(it.getHistoryEntry(wikiSite, HistoryEntry.SOURCE_FEED_FEATURED)) + }, + onBookmarkClick = { + onPageBookmarkClick(it.getHistoryEntry(wikiSite, HistoryEntry.SOURCE_FEED_FEATURED)) + } + ) + } + } + + day.topRead?.let { + item(key = "top-read-${day.age}") { + TopReadModule( + wikiSite = wikiSite, + topRead = it, + onOverflowClick = { + // TODO: implement overflow menu + }, + onPageClick = { entry -> + onPageClick(entry.getHistoryEntry(wikiSite, HistoryEntry.SOURCE_FEED_MOST_READ)) + }, + onPageOverflowClick = { pageSummary -> + // TODO: implement page overflow menu + }, + onFooterClick = { + // TODO: simplify TopReadListCard after we remove the old feed UIs. + activity?.startActivity( + TopReadArticlesActivity.newIntent(activity, TopReadListCard(it, wikiSite)) + ) + } + ) + } + } + + // TODO: insert Today's Featured Picture module here + // TODO: insert DYK module here + + day.featuredImage?.let { image -> + item(key = "tfi-${day.age}") { + FeaturedImageModule( + wikiSite = wikiSite, + featuredImage = image, + onClick = onImageClick, + onDownloadClick = onImageDownloadClick, + onShareClick = { onImageShareClick(image, day.age) } + ) + } + } + + if (day.news.isNotEmpty()) { + item(key = "news-${day.age}") { + NewsModule( + wikiSite = wikiSite, + newsItems = day.news, + onNewsClick = { newsItem -> + onNewsClick(newsItem) + }, + onOverflowClick = { + // TODO: implement overflow menu + } + ) + } + } + + if (day.onThisDay.isNotEmpty()) { + item(key = "on-this-day-${day.age}") { + OnThisDayModule( + wikiSite = wikiSite, + events = day.onThisDay.take(2), + onOverflowClick = { + // TODO: implement overflow menu + }, + onPageClick = { pageSummary -> + onPageClick(pageSummary.getHistoryEntry(wikiSite, HistoryEntry.SOURCE_FEED_ON_THIS_DAY)) + }, + onPageOverflowClick = { pageSummary -> + // TODO: implement page overflow menu + }, + onFooterClick = { + activity?.startActivity(OnThisDayActivity.newIntent(activity, day.age, -1, wikiSite, InvokeSource.ON_THIS_DAY_CARD_FOOTER)) + } + ) + } + } + + // TODO: insert Media of the day (Commons) module here + } + + item(key = "load-more-community") { + if (state.isLoadingMore) { + LoadingIndicator() + } else if (state.canLoadMore) { + LoadMoreButton( + wikiSite = wikiSite, + isCommunity = true, + onClick = onLoadMore + ) + } + } + + if (state.error != null && state.days.isNotEmpty()) { + item(key = "error-community") { + ErrorState(state.error, onRetry = onLoadMore) + } + } + } + } + } +} + +@Composable +fun ForYouContentTab( + state: ForYouContentState, + wikiSite: WikiSite, + onLoadMore: () -> Unit +) { + val context = LocalContext.current + when { + state.isInitialLoading -> { + LoadingIndicator(modifier = Modifier.fillMaxHeight()) + } + state.error != null && state.modules.isEmpty() -> { + ErrorState(state.error, onRetry = onLoadMore) + } + else -> { + val listState = rememberLazyListState() + val modules = state.modules + + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val viewportHeight = maxHeight + + LazyColumn( + state = listState, + flingBehavior = rememberSnapFlingBehavior(lazyListState = listState), + modifier = Modifier + .fillMaxSize() + .background(WikipediaTheme.colors.backgroundColor) + ) { + itemsIndexed(modules) { _, module -> + Box( + modifier = Modifier + .fillMaxWidth() + .height(viewportHeight) + ) { + AsyncImage( + model = ImageService.getRequest(context, url = module.pages.first().thumbnailUrl), + placeholder = ColorPainter(WikipediaTheme.colors.backgroundColor), + error = ColorPainter(WikipediaTheme.colors.backgroundColor), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + ) + } + } + + item(key = "load-more-foryou") { + if (state.isLoadingMore) { + LoadingIndicator() + } else if (state.canLoadMore) { + LoadMoreButton( + wikiSite = wikiSite, + isCommunity = false, + onClick = onLoadMore + ) + } + } + + if (state.error != null && state.modules.isNotEmpty()) { + item(key = "error-foryou") { + ErrorState(state.error, onRetry = onLoadMore) + } + } + } + } + } + } +} + +@Composable +fun CommunityDisclaimer( + modifier: Modifier, + wikiSite: WikiSite +) { + Box( + modifier = modifier + ) { + Row( + modifier = Modifier + .fillMaxSize() + .background( + color = WikipediaTheme.colors.borderColor, + shape = RoundedCornerShape(24.dp) + ) + .padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + modifier = Modifier.weight(1f), + text = LocalContext.current.getString(wikiSite.languageCode, R.string.explore_feed_community_disclaimer), + style = MaterialTheme.typography.bodyMedium, + color = WikipediaTheme.colors.secondaryColor + ) + Image( + modifier = Modifier.size(45.dp), + painter = painterResource(R.drawable.w_nav_mark), + contentDescription = null + ) + } + } +} +@Composable +fun DayHeader(date: LocalDate, isFirst: Boolean = true) { + val dateFormatter = DateTimeFormatter.ofPattern(DateFormat.getBestDateTimePattern(LocalLocale.current.platformLocale, "MMM dd, yyyy")) + Text( + text = if (LocalDate.now().dayOfYear == date.dayOfYear) stringResource(R.string.explore_feed_date_today, date.format(dateFormatter)) else date.format(dateFormatter), + color = WikipediaTheme.colors.secondaryColor, + fontWeight = FontWeight.Bold, + fontSize = 14.sp, + modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = if (isFirst) 16.dp else 24.dp) + ) +} + +@Composable +fun LoadMoreButton( + wikiSite: WikiSite, + isCommunity: Boolean = true, + onClick: () -> Unit +) { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + if (isCommunity) { + AppButton( + modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp), + onClick = onClick, + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(R.drawable.ic_dynamic_feed_24dp), + tint = WikipediaTheme.colors.paperColor, + contentDescription = null + ) + + Spacer(modifier = Modifier.width(16.dp)) + + Text( + text = LocalContext.current.getString(wikiSite.languageCode, R.string.explore_feed_community_load_more_label), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + color = WikipediaTheme.colors.paperColor + ) + } + } + } else { + Box( + modifier = Modifier + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + TextButton(onClick = onClick) { + Text( + text = "Load more recommendations", + color = WikipediaTheme.colors.progressiveColor, + fontWeight = FontWeight.Medium + ) + } + } + } + } +} + +@Composable +fun LoadingIndicator( + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .fillMaxWidth() + .padding(32.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + color = WikipediaTheme.colors.progressiveColor + ) + } +} + +@Composable +fun ErrorState(caught: Throwable, onRetry: () -> Unit) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + WikiErrorView( + modifier = Modifier, + caught, + errorClickEvents = WikiErrorClickEvents( + retryClickListener = { + onRetry() + } + ), + retryForGenericError = true + ) + } +} + +@Composable +fun LanguageDropDownMenu( + selectedLanguageCode: String, + onLanguageSelected: (String) -> Unit, + onManageLanguagesClick: () -> Unit, + languageState: AppLanguageState? = null +) { + var expanded by remember { mutableStateOf(false) } + Box( + modifier = Modifier + .padding(horizontal = 8.dp) + .clip(RoundedCornerShape(8.dp)) + .clickable { + expanded = true + }, + contentAlignment = Alignment.Center + ) { + Row( + modifier = Modifier + .border(width = 1.dp, color = WikipediaTheme.colors.primaryColor.copy(alpha = 0.8f), shape = RoundedCornerShape(8.dp)) + .padding(4.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + WikiLangCodeBox( + modifier = Modifier + .height(20.dp) + .widthIn(min = 20.dp), + languageCode = selectedLanguageCode, + backgroundColor = WikipediaTheme.colors.primaryColor.copy(alpha = 0.8f), + borderColor = Color.Transparent, + textColor = WikipediaTheme.colors.paperColor, + ) + Icon( + modifier = Modifier.size(16.dp), + painter = painterResource(R.drawable.ic_arrow_down_24), + contentDescription = null, + tint = WikipediaTheme.colors.primaryColor + ) + } + DropdownMenu( + expanded = expanded, + containerColor = WikipediaTheme.colors.paperColor, + onDismissRequest = { expanded = false } + ) { + val languageCodes = languageState?.appLanguageCodes.orEmpty() + repeat(languageCodes.size) { + val langCode = languageCodes[it] + DropdownMenuItem( + leadingIcon = { + WikiLangCodeBox( + modifier = Modifier + .height(20.dp) + .widthIn(min = 20.dp), + languageCode = langCode, + borderColor = WikipediaTheme.colors.secondaryColor, + textColor = WikipediaTheme.colors.secondaryColor, + ) + }, + trailingIcon = { + if (langCode == selectedLanguageCode) { + Icon( + painter = painterResource(R.drawable.ic_check_black_24dp), + contentDescription = null, + tint = WikipediaTheme.colors.secondaryColor + ) + } + }, + text = { + Text( + text = languageState?.getAppLanguageLocalizedName(langCode) ?: langCode, + style = MaterialTheme.typography.bodyLarge, + color = WikipediaTheme.colors.primaryColor + ) + }, + onClick = { + onLanguageSelected(langCode) + expanded = false + } + ) + } + + HorizontalDivider( + modifier = Modifier.padding(vertical = 8.dp), + color = WikipediaTheme.colors.borderColor + ) + + Column( + modifier = Modifier + .fillMaxWidth() + .clickable { + onManageLanguagesClick() + expanded = false + } + .padding(vertical = 8.dp), + ) { + Text( + modifier = Modifier.padding(horizontal = 16.dp), + text = LocalContext.current.getString(selectedLanguageCode, R.string.explore_feed_manage_languages_label), + style = MaterialTheme.typography.bodyLarge, + color = WikipediaTheme.colors.primaryColor + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun HomeScreenCommunityPreview() { + BaseTheme(currentTheme = Theme.LIGHT) { + HomeScreen( + wikiSite = WikiSite.preview(), + selectedTab = HomeTab.COMMUNITY, + communityContentState = CommunityContentState(isInitialLoading = true), + forYouContentState = ForYouContentState(isInitialLoading = true) + ) + } +} + +@Preview(showBackground = true) +@Composable +fun HomeScreenForYouPreview() { + BaseTheme(currentTheme = Theme.LIGHT) { + HomeScreen( + wikiSite = WikiSite.preview(), + selectedTab = HomeTab.FOR_YOU, + communityContentState = CommunityContentState(isInitialLoading = true), + forYouContentState = ForYouContentState(isInitialLoading = true) + ) + } +} + +@Preview(showBackground = true, backgroundColor = 0xFFFFFFFF) +@Composable +fun CommunityDisclaimerPreview() { + BaseTheme(currentTheme = Theme.LIGHT) { + CommunityDisclaimer( + modifier = Modifier + .padding(16.dp) + .height(72.dp), + wikiSite = WikiSite.preview() + ) + } +} + +@Preview +@Composable +fun LoadMoreButtonPreview() { + BaseTheme(currentTheme = Theme.LIGHT) { + LoadMoreButton( + wikiSite = WikiSite.preview(), + isCommunity = true, + onClick = {} + ) + } +} + +@Preview +@Composable +fun LanguageDropDownMenuPreview() { + BaseTheme(currentTheme = Theme.LIGHT) { + LanguageDropDownMenu( + selectedLanguageCode = "en", + onLanguageSelected = {}, + onManageLanguagesClick = {} + ) + } +} + +@Preview +@Composable +fun DayHeaderPreview() { + BaseTheme(currentTheme = Theme.LIGHT) { + DayHeader(LocalDate.now()) + } +} diff --git a/app/src/main/java/org/wikipedia/feed/HomeViewModel.kt b/app/src/main/java/org/wikipedia/feed/HomeViewModel.kt new file mode 100644 index 00000000000..974891ebc81 --- /dev/null +++ b/app/src/main/java/org/wikipedia/feed/HomeViewModel.kt @@ -0,0 +1,222 @@ +package org.wikipedia.feed + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.wikipedia.dataclient.ServiceFactory +import org.wikipedia.dataclient.WikiSite +import org.wikipedia.dataclient.page.PageSummary +import org.wikipedia.feed.image.FeaturedImage +import org.wikipedia.feed.news.NewsItem +import org.wikipedia.feed.onthisday.OnThisDay +import org.wikipedia.feed.personalization.homepreference.HomePreferenceType +import org.wikipedia.feed.topread.TopRead +import org.wikipedia.settings.Prefs +import java.time.LocalDate + +enum class HomeTab { COMMUNITY, FOR_YOU } + +data class DayContent( + val age: Int, + val date: LocalDate, + val featuredArticle: PageSummary? = null, + val news: List = emptyList(), + val topRead: TopRead? = null, + val featuredImage: FeaturedImage? = null, + val onThisDay: List = emptyList() +) + +sealed class ForYouModule { + abstract val pages: List + + data class BasedOnReadingHistory(override val pages: List) : ForYouModule() + data class BasedOnLocation(override val pages: List) : ForYouModule() + data class BasedOnInterests(val interest: String, override val pages: List) : ForYouModule() +} + +data class CommunityContentState( + val days: List = emptyList(), + val isInitialLoading: Boolean = false, + val isLoadingMore: Boolean = false, + val error: Throwable? = null, + val canLoadMore: Boolean = true +) + +data class ForYouContentState( + val modules: List = emptyList(), + val isInitialLoading: Boolean = false, + val isLoadingMore: Boolean = false, + val error: Throwable? = null, + val canLoadMore: Boolean = true +) + +class HomeViewModel : ViewModel() { + private val _wikiSite = MutableStateFlow(WikiSite.forLanguageCode(Prefs.homeLanguageCode)) + val wikiSite = _wikiSite.asStateFlow() + + private val _selectedTab = MutableStateFlow( + if (Prefs.homePreferenceSelection == HomePreferenceType.PERSONALIZED) HomeTab.FOR_YOU else HomeTab.COMMUNITY + ) + val selectedTab = _selectedTab.asStateFlow() + + private val _communityState = MutableStateFlow(CommunityContentState()) + val communityState = _communityState.asStateFlow() + + private val _forYouState = MutableStateFlow(ForYouContentState()) + val forYouState = _forYouState.asStateFlow() + + // "age" in days from today. 0 = today, 1 = yesterday, etc. + private var nextCommunityAge = 0 + + // Batch counter for "For you" recommendations. + private var forYouBatchIndex = 0 + + private val communityHandler = CoroutineExceptionHandler { _, throwable -> + _communityState.value = _communityState.value.copy( + isInitialLoading = false, + isLoadingMore = false, + error = throwable + ) + } + + private val forYouHandler = CoroutineExceptionHandler { _, throwable -> + _forYouState.value = _forYouState.value.copy( + isInitialLoading = false, + isLoadingMore = false, + error = throwable + ) + } + + init { + if (_selectedTab.value == HomeTab.COMMUNITY) { + loadCommunityContent() + } else { + loadForYouContent() + } + } + + fun refreshCommunityContent() { + nextCommunityAge = 0 + _communityState.update { CommunityContentState() } + loadCommunityContent() + } + + fun refreshForYouContent() { + forYouBatchIndex = 0 + _forYouState.update { ForYouContentState() } + loadForYouContent() + } + + fun selectTab(tab: HomeTab) { + _selectedTab.value = tab + if (tab == HomeTab.FOR_YOU && + _forYouState.value.modules.isEmpty() && + !_forYouState.value.isInitialLoading + ) { + loadForYouContent() + } + } + + fun updateLanguage(langCode: String) { + _wikiSite.value = WikiSite.forLanguageCode(langCode) + Prefs.homeLanguageCode = langCode + if (selectedTab.value == HomeTab.COMMUNITY) { + refreshCommunityContent() + } else { + refreshForYouContent() + } + } + + /** + * Loads the next day's community content (today on first call, then progressively older). + * Safe to call as a retry — the age only advances after a successful fetch. + */ + fun loadCommunityContent() { + if (_communityState.value.isInitialLoading || _communityState.value.isLoadingMore) return + + viewModelScope.launch(communityHandler) { + val isInitial = _communityState.value.days.isEmpty() + _communityState.value = _communityState.value.copy( + isInitialLoading = isInitial, + isLoadingMore = !isInitial, + error = null + ) + + val age = nextCommunityAge + val date = LocalDate.now().minusDays(nextCommunityAge.toLong()) + val content = ServiceFactory.getRest(wikiSite.value) + .getFeedFeatured(date.year.toString(), "%02d".format(date.monthValue), "%02d".format(date.dayOfMonth), wikiSite.value.languageCode) + + val dayContent = DayContent( + age = age, + date = date, + featuredArticle = content.tfa, + news = content.news.orEmpty(), + topRead = content.topRead, + featuredImage = content.potd, + onThisDay = content.onthisday.orEmpty() + ) + + // Advance age only after success, so retry on failure re-fetches the same day. + nextCommunityAge = age + 1 + + _communityState.value = _communityState.value.copy( + days = _communityState.value.days + dayContent, + isInitialLoading = false, + isLoadingMore = false, + error = null, + canLoadMore = true + ) + } + } + + /** + * Loads the next batch of personalized recommendations for the "For you" tab. + * Safe to call as a retry — the batch index only advances after a successful fetch. + */ + fun loadForYouContent() { + if (_forYouState.value.isInitialLoading || _forYouState.value.isLoadingMore) return + + viewModelScope.launch(forYouHandler) { + val isInitial = _forYouState.value.modules.isEmpty() + _forYouState.value = _forYouState.value.copy( + isInitialLoading = isInitial, + isLoadingMore = !isInitial, + error = null + ) + + val newModules = fetchForYouModules(forYouBatchIndex) + + // Advance batch index only after success. + forYouBatchIndex++ + + _forYouState.value = _forYouState.value.copy( + modules = _forYouState.value.modules + newModules, + isInitialLoading = false, + isLoadingMore = false, + error = null, + canLoadMore = newModules.isNotEmpty() + ) + } + } + + private suspend fun fetchForYouModules(batchIndex: Int): List { + + val sampleImageUrls = listOf( + "https://upload.wikimedia.org/wikipedia/commons/thumb/2/25/SW_Hullathy_Gram_Panchayat_Villages_Nilgiris_Nov24_A7CR_05293.jpg/1280px-SW_Hullathy_Gram_Panchayat_Villages_Nilgiris_Nov24_A7CR_05293.jpg", + "https://upload.wikimedia.org/wikipedia/commons/thumb/1/10/Color_of_Friendship.jpg/1280px-Color_of_Friendship.jpg", + "https://upload.wikimedia.org/wikipedia/commons/thumb/d/dd/MAP_Expo_Empereur_Ojin_Poup%C3%A9e_03_01_2012.jpg/1280px-MAP_Expo_Empereur_Ojin_Poup%C3%A9e_03_01_2012.jpg", + "https://upload.wikimedia.org/wikipedia/commons/thumb/a/a7/Sachsenheim_-_Ochsenbach_-_Geigersberg_-_n%C3%B6rdlicher_Teil_von_SSO_im_M%C3%A4rz.jpg/1280px-Sachsenheim_-_Ochsenbach_-_Geigersberg_-_n%C3%B6rdlicher_Teil_von_SSO_im_M%C3%A4rz.jpg", + "https://upload.wikimedia.org/wikipedia/commons/thumb/0/02/Templo_de_Rams%C3%A9s_II%2C_Abu_Simbel%2C_Egipto%2C_2022-04-02%2C_DD_26-28_HDR.jpg/1280px-Templo_de_Rams%C3%A9s_II%2C_Abu_Simbel%2C_Egipto%2C_2022-04-02%2C_DD_26-28_HDR.jpg", + ) + + val modules = sampleImageUrls.map { + ForYouModule.BasedOnInterests("Art", listOf(PageSummary("", "", "", "", thumbnail = it, ""))) + } + return modules + } +} diff --git a/app/src/main/java/org/wikipedia/feed/featured/FeaturedArticleModule.kt b/app/src/main/java/org/wikipedia/feed/featured/FeaturedArticleModule.kt new file mode 100644 index 00000000000..f927cc26017 --- /dev/null +++ b/app/src/main/java/org/wikipedia/feed/featured/FeaturedArticleModule.kt @@ -0,0 +1,215 @@ +package org.wikipedia.feed.featured + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.ColorPainter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import org.wikipedia.R +import org.wikipedia.compose.components.HtmlText +import org.wikipedia.compose.theme.BaseTheme +import org.wikipedia.compose.theme.WikipediaTheme +import org.wikipedia.dataclient.WikiSite +import org.wikipedia.dataclient.page.PageSummary +import org.wikipedia.extensions.getString +import org.wikipedia.feed.CommunityModuleHeader +import org.wikipedia.theme.Theme +import org.wikipedia.views.imageservice.ImageService + +@Composable +fun FeaturedArticleModule( + wikiSite: WikiSite, + article: PageSummary, + onPageClick: (article: PageSummary) -> Unit = {}, + onOverflowClick: () -> Unit = {}, + onShareClick: (article: PageSummary) -> Unit = {}, + onBookmarkClick: (article: PageSummary) -> Unit = {} +) { + val context = LocalContext.current + + Column( + modifier = Modifier + .fillMaxWidth() + .background(color = WikipediaTheme.colors.paperColor) + ) { + CommunityModuleHeader( + wikiSite = wikiSite, + titleResId = R.string.view_featured_article_card_title, + subTitleResId = R.string.explore_feed_featured_article_subtitle, + onOverflowClick = onOverflowClick + ) + + Box( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, bottom = 24.dp) + .clip(RoundedCornerShape(16.dp)) + .clickable { onPageClick(article) } + ) { + if (article.thumbnailUrl.isNullOrEmpty()) { + val color = colorResource(listOf(R.color.maroon800, R.color.purple800, R.color.pink800).random()) + Box( + modifier = Modifier.fillMaxWidth().height(415.dp).background(color) + ) + } else { + AsyncImage( + model = article.thumbnailUrl?.let { ImageService.getRequest(context, url = it) }, + placeholder = ColorPainter(WikipediaTheme.colors.backgroundColor), + error = ColorPainter(WikipediaTheme.colors.backgroundColor), + contentDescription = article.displayTitle, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxWidth() + .height(415.dp) + ) + Box( + modifier = Modifier.fillMaxWidth().height(415.dp).background(Color(0, 0, 0, 100)) + ) + } + + Row( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(12.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + IconButton( + modifier = Modifier.size(48.dp), + onClick = { onBookmarkClick(article) } + ) { + Box( + modifier = Modifier + .background( + color = WikipediaTheme.colors.backgroundColor, + shape = CircleShape + ) + .size(40.dp), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(R.drawable.ic_bookmark_border_white_24dp), + contentDescription = context.getString(wikiSite.languageCode, R.string.feed_card_add_to_default_list), + tint = WikipediaTheme.colors.primaryColor, + modifier = Modifier.size(24.dp) + ) + } + } + IconButton( + modifier = Modifier.size(48.dp), + onClick = { onShareClick(article) } + ) { + Box( + modifier = Modifier + .background( + color = WikipediaTheme.colors.backgroundColor, + shape = CircleShape + ) + .size(40.dp), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(R.drawable.ic_share), + contentDescription = context.getString(wikiSite.languageCode, R.string.view_featured_image_card_share), + tint = WikipediaTheme.colors.primaryColor, + modifier = Modifier.size(24.dp) + ) + } + } + } + + Column( + modifier = Modifier + .align(Alignment.BottomStart) + .fillMaxWidth() + .padding(16.dp) + .clip(RoundedCornerShape(8.dp)) + .background(if (article.thumbnailUrl.isNullOrEmpty()) Color.Transparent else WikipediaTheme.colors.paperColor.copy(alpha = 0.92f)) + .padding(16.dp) + ) { + HtmlText( + text = article.displayTitle, + color = if (article.thumbnailUrl.isNullOrEmpty()) Color.White else WikipediaTheme.colors.primaryColor, + style = MaterialTheme.typography.titleLarge.copy( + fontFamily = FontFamily.Serif + ), + maxLines = 3 + ) + article.description?.let { description -> + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = description, + color = if (article.thumbnailUrl.isNullOrEmpty()) Color.White.copy(alpha = 0.8f) else WikipediaTheme.colors.secondaryColor, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + article.extract?.let { extract -> + HorizontalDivider( + modifier = Modifier.padding(vertical = 12.dp).width(48.dp), + thickness = 1.dp, + color = if (article.thumbnailUrl.isNullOrEmpty()) Color.White.copy(alpha = 0.8f) else WikipediaTheme.colors.secondaryColor.copy(alpha = 0.2f) + ) + Text( + text = extract, + color = if (article.thumbnailUrl.isNullOrEmpty()) Color.White else WikipediaTheme.colors.primaryColor, + style = MaterialTheme.typography.bodyMedium, + maxLines = if (article.thumbnailUrl.isNullOrEmpty()) 6 else 4, + overflow = TextOverflow.Ellipsis + ) + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun FeaturedArticleCardWithImagePreview() { + BaseTheme(currentTheme = Theme.LIGHT) { + FeaturedArticleModule( + wikiSite = WikiSite.preview(), + article = PageSummary("Lorem ipsum", "Lorem ipsum", "Lorem ipsum", "Lorem ipsum", thumbnail = "test.jpg", "") + ) + } +} + +@Preview(showBackground = true) +@Composable +fun FeaturedArticleCardNoImagePreview() { + BaseTheme(currentTheme = Theme.LIGHT) { + FeaturedArticleModule( + wikiSite = WikiSite.preview(), + article = PageSummary("Lorem ipsum", "Lorem ipsum", "Lorem ipsum", "Lorem ipsum", thumbnail = null, "") + ) + } +} diff --git a/app/src/main/java/org/wikipedia/feed/image/FeaturedImage.kt b/app/src/main/java/org/wikipedia/feed/image/FeaturedImage.kt index 887aded7675..7021e64c5b5 100644 --- a/app/src/main/java/org/wikipedia/feed/image/FeaturedImage.kt +++ b/app/src/main/java/org/wikipedia/feed/image/FeaturedImage.kt @@ -6,12 +6,10 @@ import org.wikipedia.gallery.ImageInfo import org.wikipedia.util.StringUtil @Serializable -class FeaturedImage : GalleryItem() { - - val title = "" - - val image = ImageInfo() - +class FeaturedImage( + val title: String = "", + val image: ImageInfo = ImageInfo() +) : GalleryItem() { init { titles = Titles(title, StringUtil.addUnderscores(title), title) original.source = image.source diff --git a/app/src/main/java/org/wikipedia/feed/image/FeaturedImageModule.kt b/app/src/main/java/org/wikipedia/feed/image/FeaturedImageModule.kt new file mode 100644 index 00000000000..d32a5b508d9 --- /dev/null +++ b/app/src/main/java/org/wikipedia/feed/image/FeaturedImageModule.kt @@ -0,0 +1,224 @@ +package org.wikipedia.feed.image + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.ColorPainter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLinkStyles +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import org.wikipedia.R +import org.wikipedia.compose.components.HtmlText +import org.wikipedia.compose.theme.BaseTheme +import org.wikipedia.compose.theme.WikipediaTheme +import org.wikipedia.dataclient.WikiSite +import org.wikipedia.extensions.getString +import org.wikipedia.feed.CommunityModuleHeader +import org.wikipedia.theme.Theme +import org.wikipedia.views.imageservice.ImageService + +@Composable +fun FeaturedImageModule( + wikiSite: WikiSite, + featuredImage: FeaturedImage, + onClick: (featuredImage: FeaturedImage) -> Unit = {}, + onOverflowClick: (featuredImage: FeaturedImage) -> Unit = {}, + onDownloadClick: (featuredImage: FeaturedImage) -> Unit = {}, + onShareClick: (featuredImage: FeaturedImage) -> Unit = {}, +) { + val context = LocalContext.current + + Column( + Modifier.background(color = WikipediaTheme.colors.paperColor) + ) { + CommunityModuleHeader( + wikiSite = wikiSite, + titleResId = R.string.view_featured_image_card_title, + subTitleResId = R.string.explore_feed_potd_subtitle, + contextIconResId = R.drawable.ic_commons_logo, + onOverflowClick = { onOverflowClick(featuredImage) } + ) + + Box( + modifier = Modifier + .fillMaxWidth().padding(bottom = 24.dp) + .clickable { + onClick(featuredImage) + } + ) { + AsyncImage( + model = ImageService.getRequest(context, url = featuredImage.thumbnailUrl), + placeholder = ColorPainter(WikipediaTheme.colors.backgroundColor), + error = ColorPainter(WikipediaTheme.colors.backgroundColor), + contentDescription = featuredImage.title, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxWidth() + .height(520.dp) + ) + Row( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(12.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + IconButton( + onClick = { onDownloadClick(featuredImage) }, + modifier = Modifier.size(48.dp) + ) { + Box( + modifier = Modifier + .size(40.dp) + .background( + color = WikipediaTheme.colors.backgroundColor, + shape = CircleShape + ), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(R.drawable.ic_download_24px), + contentDescription = context.getString(wikiSite.languageCode, R.string.view_featured_image_card_download), + tint = WikipediaTheme.colors.primaryColor, + modifier = Modifier.size(24.dp) + ) + } + } + IconButton( + onClick = { onShareClick(featuredImage) }, + modifier = Modifier.size(48.dp) + ) { + Box( + modifier = Modifier + .size(40.dp) + .background( + color = WikipediaTheme.colors.backgroundColor, + shape = CircleShape + ), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(R.drawable.ic_share), + contentDescription = context.getString(wikiSite.languageCode, R.string.view_featured_image_card_share), + tint = WikipediaTheme.colors.primaryColor, + modifier = Modifier.size(24.dp) + ) + } + } + } + + Box( + modifier = Modifier + .align(Alignment.BottomStart) + .fillMaxWidth() + .background(Brush.verticalGradient( + colorStops = arrayOf( + 0.0f to Color.Transparent, + 0.18f to Color.Black.copy(alpha = 0.05f), + 0.38f to Color.Black.copy(alpha = 0.15f), + 0.58f to Color.Black.copy(alpha = 0.30f), + 0.76f to Color.Black.copy(alpha = 0.50f), + 0.90f to Color.Black.copy(alpha = 0.7f), + 1.0f to Color.Black.copy(alpha = 0.85f) + ) + )) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, top = 80.dp, bottom = 16.dp) + ) { + HtmlText( + modifier = Modifier.padding(bottom = 8.dp), + text = featuredImage.description.text, + color = Color.White, + style = MaterialTheme.typography.bodySmall, + maxLines = 2, + linkStyle = TextLinkStyles( + style = SpanStyle( + color = Color.White, + textDecoration = TextDecoration.Underline + ) + ) + ) + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + painter = painterResource(R.drawable.ic_user_avatar), + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(16.dp).padding(2.dp) + ) + HtmlText( + modifier = Modifier.weight(1f).padding(start = 4.dp), + text = "${featuredImage.artist?.html.orEmpty()} - ${featuredImage.credit?.html.orEmpty()}", + color = Color.White, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + linkStyle = TextLinkStyles( + style = SpanStyle( + color = Color.White, + textDecoration = TextDecoration.Underline + ) + ) + ) + } + Row( + modifier = Modifier.padding(top = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + painter = painterResource(featuredImage.license?.licenseIcon() ?: R.drawable.ic_info_outline_black_24dp), + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(16.dp) + ) + HtmlText( + modifier = Modifier.weight(1f).padding(start = 4.dp), + text = "${featuredImage.license?.licenseName.orEmpty()}", + color = Color.White, + style = MaterialTheme.typography.bodySmall, + linkStyle = TextLinkStyles( + style = SpanStyle( + color = Color.White, + textDecoration = TextDecoration.Underline + ) + ) + ) + } + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun FeaturedImageCardPreview() { + BaseTheme(currentTheme = Theme.LIGHT) { + FeaturedImageModule( + wikiSite = WikiSite.preview(), + FeaturedImage("Lorem ipsum") + ) + } +} diff --git a/app/src/main/java/org/wikipedia/feed/news/NewsItem.kt b/app/src/main/java/org/wikipedia/feed/news/NewsItem.kt index 5880e959644..e9c60aaef01 100644 --- a/app/src/main/java/org/wikipedia/feed/news/NewsItem.kt +++ b/app/src/main/java/org/wikipedia/feed/news/NewsItem.kt @@ -2,6 +2,7 @@ package org.wikipedia.feed.news import android.net.Uri import android.os.Parcelable +import androidx.core.net.toUri import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable import org.wikipedia.Constants @@ -22,12 +23,21 @@ class NewsItem( fun thumb(): Uri? { return getFirstImageUri(links)?.let { - Uri.parse(ImageUrlUtil.getUrlForPreferredSize( - it.toString(), Constants.PREFERRED_CARD_THUMBNAIL_SIZE)) + ImageUrlUtil.getUrlForPreferredSize( + it.toString(), Constants.PREFERRED_CARD_THUMBNAIL_SIZE + ).toUri() + } + } + + fun thumbUrl(): String? { + return getFirstImageUri(links)?.let { + ImageUrlUtil.getUrlForPreferredSize( + it.toString(), Constants.PREFERRED_CARD_THUMBNAIL_SIZE + ) } } private fun getFirstImageUri(links: List): Uri? { - return links.firstOrNull { !it.thumbnailUrl.isNullOrEmpty() }?.run { Uri.parse(thumbnailUrl) } + return links.firstOrNull { !it.thumbnailUrl.isNullOrEmpty() }?.thumbnailUrl?.toUri() } } diff --git a/app/src/main/java/org/wikipedia/feed/news/NewsModule.kt b/app/src/main/java/org/wikipedia/feed/news/NewsModule.kt new file mode 100644 index 00000000000..846ee0249cc --- /dev/null +++ b/app/src/main/java/org/wikipedia/feed/news/NewsModule.kt @@ -0,0 +1,215 @@ +package org.wikipedia.feed.news + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.graphics.painter.ColorPainter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import org.wikipedia.R +import org.wikipedia.compose.components.PageIndicator +import org.wikipedia.compose.theme.BaseTheme +import org.wikipedia.compose.theme.WikipediaTheme +import org.wikipedia.dataclient.WikiSite +import org.wikipedia.dataclient.page.PageSummary +import org.wikipedia.feed.CommunityModuleHeader +import org.wikipedia.theme.Theme +import org.wikipedia.util.StringUtil +import org.wikipedia.views.imageservice.ImageService + +@Composable +fun NewsModule( + wikiSite: WikiSite, + newsItems: List, + onNewsClick: (item: NewsItem) -> Unit = {}, + onOverflowClick: () -> Unit = {} +) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(color = WikipediaTheme.colors.paperColor) + ) { + + val pagerState = rememberPagerState(pageCount = { newsItems.size }) + + CommunityModuleHeader( + wikiSite = wikiSite, + titleResId = R.string.view_card_news_title, + subTitleResId = R.string.explore_feed_in_the_news_subtitle, + onOverflowClick = onOverflowClick + ) + + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxWidth() + ) { page -> + NewsItemContent( + newsItem = newsItems[page], + onItemClick = onNewsClick + ) + } + + if (newsItems.size > 1) { + PageIndicator( + modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), + pagerState = pagerState + ) + } + Spacer(modifier = Modifier.height(24.dp)) + } +} + +@Composable +fun NewsItemContent( + newsItem: NewsItem, + onItemClick: (NewsItem) -> Unit = {}, +) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp) + .clickable { onItemClick(newsItem) } + ) { + if (newsItem.thumbUrl().isNullOrEmpty()) { + val color = colorResource(listOf(R.color.maroon800, R.color.purple800, R.color.pink800).random()) + Box( + modifier = Modifier.fillMaxWidth().height(415.dp).background(color) + ) + } else { + AsyncImage( + model = newsItem.thumbUrl()?.let { ImageService.getRequest(LocalContext.current, url = it) }, + placeholder = ColorPainter(WikipediaTheme.colors.backgroundColor), + error = ColorPainter(WikipediaTheme.colors.backgroundColor), + contentDescription = newsItem.story, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxWidth().height(415.dp) + ) + // Dark overlay on top of image: + Box( + modifier = Modifier.fillMaxWidth().height(415.dp).background(Color(0, 0, 0, 100)) + ) + } + + if (newsItem.story.isNotEmpty()) { + if (newsItem.thumbUrl().isNullOrEmpty()) { + Text( + modifier = Modifier + .align(Alignment.BottomStart) + .fillMaxWidth() + .padding(32.dp), + text = StringUtil.fromHtml(removeItalicParenthetical(newsItem.story)).toString(), + color = Color.White, + style = MaterialTheme.typography.bodyMedium, + maxLines = 8, + overflow = TextOverflow.Ellipsis + ) + } else { + Box( + modifier = Modifier + .align(Alignment.BottomStart) + .fillMaxWidth() + .padding(16.dp) + .clip(RoundedCornerShape(8.dp)) + .background(WikipediaTheme.colors.paperColor.copy(alpha = 0.92f)) + .padding(16.dp) + ) { + Text( + text = StringUtil.fromHtml(removeItalicParenthetical(newsItem.story)).toString(), + color = WikipediaTheme.colors.primaryColor, + style = MaterialTheme.typography.bodyMedium, + maxLines = 4, + overflow = TextOverflow.Ellipsis + ) + } + } + } + } +} + +private fun removeItalicParenthetical(text: String): String { + return text.replace("(.*?)".toRegex(), "") +} + +@Preview(showBackground = true) +@Composable +fun NewsCardPreviewWithImage() { + val pageSummary = PageSummary( + displayTitle = "Dog and Cat", + prefixTitle = "Dog_and_Cat", + description = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", + extract = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", + thumbnail = "https://upload.wikimedia.org/wikipedia/commons/thumb/4/4a/Commons-logo.svg/1200px-Commons-logo.svg.png", + lang = "en" + ) + BaseTheme(currentTheme = Theme.LIGHT) { + NewsModule( + wikiSite = WikiSite.preview(), + newsItems = listOf( + NewsItem( + story = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", + links = listOf(pageSummary) + ), + NewsItem( + story = "Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.", + links = listOf(pageSummary) + ), + NewsItem( + story = "Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.", + links = listOf(pageSummary) + ) + ) + ) + } +} + +@Preview(showBackground = true) +@Composable +fun NewsCardPreviewNoImage() { + val pageSummary = PageSummary( + displayTitle = "Dog and Cat", + prefixTitle = "Dog_and_Cat", + description = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", + extract = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", + thumbnail = null, + lang = "en" + ) + BaseTheme(currentTheme = Theme.LIGHT) { + NewsModule( + wikiSite = WikiSite.preview(), + newsItems = listOf( + NewsItem( + story = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", + links = listOf(pageSummary) + ), + NewsItem( + story = "Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.", + links = listOf(pageSummary) + ), + NewsItem( + story = "Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.", + links = listOf(pageSummary) + ) + ) + ) + } +} diff --git a/app/src/main/java/org/wikipedia/feed/onboarding/ExploreFeedBuildingActivity.kt b/app/src/main/java/org/wikipedia/feed/onboarding/ExploreFeedBuildingActivity.kt new file mode 100644 index 00000000000..fd20b421b4b --- /dev/null +++ b/app/src/main/java/org/wikipedia/feed/onboarding/ExploreFeedBuildingActivity.kt @@ -0,0 +1,123 @@ +package org.wikipedia.feed.onboarding + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +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.layout.safeDrawingPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.airbnb.lottie.compose.LottieAnimation +import com.airbnb.lottie.compose.LottieCompositionSpec +import com.airbnb.lottie.compose.LottieConstants +import com.airbnb.lottie.compose.rememberLottieComposition +import kotlinx.coroutines.delay +import org.wikipedia.R +import org.wikipedia.activity.BaseActivity +import org.wikipedia.compose.theme.BaseTheme +import org.wikipedia.compose.theme.WikipediaTheme +import org.wikipedia.theme.Theme + +class ExploreFeedBuildingActivity : BaseActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + BaseTheme { + ExploreFeedBuildingScreen( + onFinished = { + setResult(RESULT_OK) + finish() + } + ) + } + } + } + + companion object { + fun newIntent(context: Context): Intent { + return Intent(context, ExploreFeedBuildingActivity::class.java) + } + } +} + +@Composable +fun ExploreFeedBuildingScreen( + modifier: Modifier = Modifier, + onFinished: () -> Unit +) { + val animationAsset by rememberLottieComposition(LottieCompositionSpec.Asset("lottie/explore_feed_building.lottie")) + + LaunchedEffect(Unit) { + // TODO: actual loading logic here + delay(1000) + onFinished() + } + + Scaffold( + modifier = modifier + .safeDrawingPadding(), + containerColor = WikipediaTheme.colors.paperColor + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(horizontal = 24.dp) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + modifier = Modifier + .fillMaxWidth(), + text = stringResource(R.string.explore_feed_building_text), + style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.Medium), + color = WikipediaTheme.colors.primaryColor + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Box( + modifier = Modifier.height(185.dp) + ) { + LottieAnimation( + modifier = Modifier.fillMaxSize(), + composition = animationAsset, + iterations = LottieConstants.IterateForever + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun ExploreFeedBuildingScreenPreview() { + BaseTheme( + currentTheme = Theme.LIGHT + ) { + ExploreFeedBuildingScreen( + onFinished = { } + ) + } +} diff --git a/app/src/main/java/org/wikipedia/feed/onboarding/ExploreFeedUpdatePromptActivity.kt b/app/src/main/java/org/wikipedia/feed/onboarding/ExploreFeedUpdatePromptActivity.kt new file mode 100644 index 00000000000..d14d79bc607 --- /dev/null +++ b/app/src/main/java/org/wikipedia/feed/onboarding/ExploreFeedUpdatePromptActivity.kt @@ -0,0 +1,142 @@ +package org.wikipedia.feed.onboarding + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.wikipedia.R +import org.wikipedia.activity.BaseActivity +import org.wikipedia.compose.components.OnboardingItem +import org.wikipedia.compose.components.OnboardingListItem +import org.wikipedia.compose.components.TwoButtonBottomBar +import org.wikipedia.compose.theme.BaseTheme +import org.wikipedia.compose.theme.WikipediaTheme +import org.wikipedia.feed.personalization.PersonalizationActivity +import org.wikipedia.settings.Prefs +import org.wikipedia.theme.Theme + +private val onboardingItems = listOf( + OnboardingItem( + icon = R.drawable.ic_home_24dp, + title = R.string.explore_feed_new_update_prompt_new_name_title, + subTitle = R.string.explore_feed_new_update_prompt_new_name_description + ), + OnboardingItem( + icon = R.drawable.ic_split_scene_24dp, + title = R.string.explore_feed_new_update_prompt_two_ways_title, + subTitle = R.string.explore_feed_new_update_prompt_two_ways_description + ), + OnboardingItem( + icon = R.drawable.ic_baseline_tune_24, + title = R.string.explore_feed_new_update_prompt_control_title, + subTitle = R.string.explore_feed_new_update_prompt_control_description + ), + OnboardingItem( + icon = R.drawable.ic_public_24dp, + title = R.string.explore_feed_new_update_prompt_language_title, + subTitle = R.string.explore_feed_new_update_prompt_language_description + ) +) + +class ExploreFeedUpdatePromptActivity : BaseActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + Prefs.isExploreFeedUpdatePromptShown = true + setContent { + BaseTheme { + ExploreFeedUpdatePromptScreen( + onSetItUpForMeClick = { + finish() + }, + onCustomizeFeedClick = { + startActivity(PersonalizationActivity.newIntent(this)) + finish() + } + ) + } + } + } + + companion object { + fun newIntent(context: Context): Intent { + return Intent(context, ExploreFeedUpdatePromptActivity::class.java) + } + } +} + +@Composable +fun ExploreFeedUpdatePromptScreen( + modifier: Modifier = Modifier, + onSetItUpForMeClick: () -> Unit, + onCustomizeFeedClick: () -> Unit +) { + Scaffold( + modifier = modifier + .safeDrawingPadding(), + containerColor = WikipediaTheme.colors.paperColor, + bottomBar = { + TwoButtonBottomBar( + primaryButtonText = stringResource(R.string.explore_feed_new_update_prompt_primary_button_text), + secondaryButtonText = stringResource(R.string.explore_feed_new_update_prompt_secondary_button_text), + onPrimaryOnClick = onCustomizeFeedClick, + onSecondaryOnClick = onSetItUpForMeClick + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(horizontal = 24.dp) + .verticalScroll(rememberScrollState()), + ) { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(top = 58.dp, bottom = 32.dp), + textAlign = TextAlign.Center, + text = stringResource(R.string.explore_feed_new_update_prompt_title), + style = MaterialTheme.typography.headlineLarge.copy(fontWeight = FontWeight.Medium), + color = WikipediaTheme.colors.primaryColor + ) + + onboardingItems.forEach { onboardingItem -> + OnboardingListItem( + modifier = Modifier + .padding(bottom = 24.dp), + item = onboardingItem + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun ExploreFeedUpdatePromptScreenPreview() { + BaseTheme( + currentTheme = Theme.LIGHT + ) { + ExploreFeedUpdatePromptScreen( + onSetItUpForMeClick = {}, + onCustomizeFeedClick = {} + ) + } +} diff --git a/app/src/main/java/org/wikipedia/feed/onthisday/OnThisDayModule.kt b/app/src/main/java/org/wikipedia/feed/onthisday/OnThisDayModule.kt new file mode 100644 index 00000000000..57c8c294478 --- /dev/null +++ b/app/src/main/java/org/wikipedia/feed/onthisday/OnThisDayModule.kt @@ -0,0 +1,320 @@ +package org.wikipedia.feed.onthisday + +import android.content.Context +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import org.wikipedia.R +import org.wikipedia.compose.components.HtmlText +import org.wikipedia.compose.theme.BaseTheme +import org.wikipedia.compose.theme.WikipediaTheme +import org.wikipedia.dataclient.WikiSite +import org.wikipedia.dataclient.page.PageSummary +import org.wikipedia.extensions.getString +import org.wikipedia.feed.CommunityModuleHeader +import org.wikipedia.theme.Theme +import org.wikipedia.util.DateUtil + +@Composable +fun OnThisDayModule( + wikiSite: WikiSite, + events: List, + onOverflowClick: () -> Unit = {}, + onPageClick: (page: PageSummary) -> Unit = {}, + onPageOverflowClick: (PageSummary) -> Unit = {}, + onFooterClick: () -> Unit = {} +) { + val context = LocalContext.current + Column( + modifier = Modifier + .fillMaxWidth() + .background(color = WikipediaTheme.colors.backgroundColor) + ) { + CommunityModuleHeader( + wikiSite = wikiSite, + titleResId = R.string.on_this_day_card_title, + subTitleResId = R.string.explore_feed_on_this_day_subtitle, + onOverflowClick = onOverflowClick + ) + + events.forEachIndexed { index, event -> + EventRow( + context = context, + wikiSite = wikiSite, + isFirst = index == 0, + event = event, + onPageClick = onPageClick, + onPageOverflowClick = onPageOverflowClick + ) + } + + TextButton( + modifier = Modifier + .align(Alignment.End) + .padding(bottom = 16.dp), + onClick = onFooterClick + ) { + Text( + text = context.getString(wikiSite.languageCode, R.string.more_events_text), + style = MaterialTheme.typography.labelLarge, + color = WikipediaTheme.colors.progressiveColor, + fontWeight = FontWeight.Medium + ) + Spacer(modifier = Modifier.width(8.dp)) + Icon( + painter = painterResource(R.drawable.ic_arrow_forward_black_24dp), + contentDescription = context.getString(wikiSite.languageCode, R.string.more_events_text), + tint = WikipediaTheme.colors.progressiveColor + ) + } + } +} + +@Composable +private fun EventRow( + context: Context, + wikiSite: WikiSite, + event: OnThisDay.Event, + isFirst: Boolean, + onPageClick: (page: PageSummary) -> Unit = {}, + onPageOverflowClick: (PageSummary) -> Unit = {}, +) { + Box(modifier = Modifier.fillMaxWidth()) { + + Column( + modifier = Modifier.matchParentSize() + ) { + Box( + modifier = Modifier + .padding(start = 21.dp) + .width(1.dp) + .weight(1f) + .background(WikipediaTheme.colors.borderColor) + ) + } + + if (isFirst) { + Box( + modifier = Modifier + .padding(start = 16.dp, top = 8.dp) + .size(12.dp) + .clip(CircleShape) + .background(WikipediaTheme.colors.progressiveColor) + ) + } else { + Box( + modifier = Modifier + .padding(start = 16.dp, top = 24.dp) + .size(11.dp) + .clip(CircleShape) + .border(1.dp, WikipediaTheme.colors.progressiveColor, CircleShape) + .padding(3.dp) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .clip(CircleShape) + .background(WikipediaTheme.colors.progressiveColor) + ) + } + } + + Column(modifier = Modifier.fillMaxWidth()) { + if (!isFirst) { + Spacer(modifier = Modifier.height(16.dp)) + } + + Text( + modifier = Modifier.padding(start = 36.dp), + text = event.year.toString(), + style = MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.W500 + ), + color = WikipediaTheme.colors.progressiveColor + ) + Text( + modifier = Modifier.padding(start = 36.dp, top = 8.dp), + text = DateUtil.getYearDifferenceString(context, event.year, wikiSite.languageCode), + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.W600 + ), + color = WikipediaTheme.colors.primaryColor + ) + Text( + modifier = Modifier.padding(start = 36.dp, end = 16.dp, top = 8.dp), + text = event.text, + color = WikipediaTheme.colors.primaryColor + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Row( + modifier = Modifier + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + Spacer(modifier = Modifier.width(20.dp)) + event.pages.forEach { page -> + OnThisDayPageItem( + context, + wikiSite, + page, + onPageClick = onPageClick, + onPageOverflowClick = onPageOverflowClick + ) + } + Spacer(modifier = Modifier.width(16.dp)) + } + Spacer(modifier = Modifier.height(8.dp)) + } + } +} + +@Composable +private fun OnThisDayPageItem( + context: Context, + wikiSite: WikiSite, + pageSummary: PageSummary, + onPageClick: (PageSummary) -> Unit, + onPageOverflowClick: (PageSummary) -> Unit = {}, +) { + Surface( + shape = RoundedCornerShape(16.dp), + border = BorderStroke(1.dp, WikipediaTheme.colors.borderColor), + color = WikipediaTheme.colors.paperColor, + modifier = Modifier.width(260.dp), + onClick = { onPageClick(pageSummary) } + ) { + Row( + modifier = Modifier.padding(start = 16.dp, top = 16.dp, bottom = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + HtmlText( + text = pageSummary.displayTitle, + color = WikipediaTheme.colors.primaryColor, + style = MaterialTheme.typography.titleSmall.copy( + fontWeight = FontWeight.W600 + ) + ) + if (!pageSummary.description.isNullOrEmpty()) { + Text( + text = pageSummary.description.orEmpty(), + style = MaterialTheme.typography.bodySmall, + color = WikipediaTheme.colors.secondaryColor, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } + + pageSummary.thumbnailUrl?.let { + AsyncImage( + model = pageSummary.thumbnailUrl, + contentDescription = null, + modifier = Modifier + .padding(start = 4.dp) + .size(56.dp) + .clip(RoundedCornerShape(8.dp)), + contentScale = ContentScale.Crop + ) + } + + Spacer(modifier = Modifier.width(4.dp)) + + IconButton( + onClick = { + onPageOverflowClick(pageSummary) + }, + content = { + Icon( + painter = painterResource(R.drawable.ic_more_vert_white_24dp), + contentDescription = context.getString(wikiSite.languageCode, R.string.menu_feed_overflow_label), + tint = WikipediaTheme.colors.secondaryColor + ) + } + ) + } + } +} + +@Preview +@Composable +fun OnThisDayPageItemPreview() { + val pageSummary = PageSummary( + displayTitle = "Dog and Cat", + prefixTitle = "Dog_and_Cat", + description = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", + extract = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", + thumbnail = "https://upload.wikimedia.org/wikipedia/commons/thumb/4/4a/Commons-logo.svg/1200px-Commons-logo.svg.png", + lang = "en" + ) + BaseTheme(currentTheme = Theme.LIGHT) { + OnThisDayPageItem( + LocalContext.current, + wikiSite = WikiSite.preview(), + pageSummary = pageSummary, + onPageClick = {}, + onPageOverflowClick = {} + ) + } +} + +@Preview +@Composable +fun OnThisDayModulePreview() { + val pageSummary = PageSummary( + displayTitle = "Lorem ipsum", + prefixTitle = "Lorem_ipsum", + description = "Lorem ipsum dolor sit amet, consectetur adipiscing elit.", + extract = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", + thumbnail = "https://upload.wikimedia.org/wikipedia/commons/thumb/4/4a/Commons-logo.svg/1200px-Commons-logo.svg.png", + lang = "en" + ) + + val event = OnThisDay.Event( + year = 2023, + text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit.", + pages = listOf(pageSummary, pageSummary, pageSummary) + ) + + BaseTheme(currentTheme = Theme.LIGHT) { + OnThisDayModule( + wikiSite = WikiSite.preview(), + events = listOf(event, event) + ) + } +} diff --git a/app/src/main/java/org/wikipedia/feed/personalization/OnboardingCuriosityScreen.kt b/app/src/main/java/org/wikipedia/feed/personalization/OnboardingCuriosityScreen.kt new file mode 100644 index 00000000000..b94a3097670 --- /dev/null +++ b/app/src/main/java/org/wikipedia/feed/personalization/OnboardingCuriosityScreen.kt @@ -0,0 +1,90 @@ +package org.wikipedia.feed.personalization + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +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.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import coil3.compose.SubcomposeAsyncImage +import coil3.compose.SubcomposeAsyncImageContent +import coil3.request.ImageRequest +import coil3.request.allowHardware +import org.wikipedia.R +import org.wikipedia.compose.theme.BaseTheme +import org.wikipedia.compose.theme.WikipediaTheme +import org.wikipedia.theme.Theme +import org.wikipedia.yearinreview.LoadingIndicator + +@Composable +fun OnboardingCuriosityScreen( + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .fillMaxSize() + .background(WikipediaTheme.colors.paperColor), + horizontalAlignment = Alignment.CenterHorizontally + ) { + + Spacer(modifier = Modifier.weight(1f)) + + SubcomposeAsyncImage( + modifier = Modifier + .size(125.dp), + model = ImageRequest.Builder(LocalContext.current) + .data(R.drawable.yir_puzzle_browser) + .allowHardware(false) + .build(), + loading = { LoadingIndicator() }, + success = { + SubcomposeAsyncImageContent() + }, + contentDescription = stringResource(R.string.explore_feed_onboarding_curiosity_title), + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 16.dp), + text = stringResource(R.string.explore_feed_onboarding_curiosity_title), + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Medium, + color = WikipediaTheme.colors.primaryColor + ) + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + text = stringResource(R.string.explore_feed_onboarding_curiosity_description), + style = MaterialTheme.typography.bodyLarge, + color = WikipediaTheme.colors.primaryColor + ) + + Spacer(modifier = Modifier.weight(1f)) + } +} + +@Preview +@Composable +private fun OnboardingCuriosityScreenPreview() { + BaseTheme ( + currentTheme = Theme.LIGHT + ) { + OnboardingCuriosityScreen() + } +} diff --git a/app/src/main/java/org/wikipedia/feed/personalization/PersonalizationActivity.kt b/app/src/main/java/org/wikipedia/feed/personalization/PersonalizationActivity.kt new file mode 100644 index 00000000000..3d985e3dd72 --- /dev/null +++ b/app/src/main/java/org/wikipedia/feed/personalization/PersonalizationActivity.kt @@ -0,0 +1,69 @@ +package org.wikipedia.feed.personalization + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.viewModels +import org.wikipedia.Constants +import org.wikipedia.activity.BaseActivity +import org.wikipedia.compose.theme.BaseTheme +import org.wikipedia.extensions.parcelableExtra +import org.wikipedia.feed.onboarding.ExploreFeedBuildingActivity +import org.wikipedia.page.PageTitle +import org.wikipedia.search.SearchActivity +import org.wikipedia.util.FeedbackUtil + +class PersonalizationActivity : BaseActivity() { + + private val viewModel: PersonalizationViewModel by viewModels { PersonalizationViewModel.Factory } + + private val searchLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + if (it.resultCode == SearchActivity.RESULT_LINK_SUCCESS) { + val pageTitle = it.data?.parcelableExtra(SearchActivity.EXTRA_RETURN_LINK_TITLE) ?: return@registerForActivityResult + viewModel.addArticleFromSearch(pageTitle) + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + BaseTheme { + PersonalizationScreen( + viewModel = viewModel, + screens = listOf( + PersonalizationPage.CURIOSITY, + PersonalizationPage.INTERESTS, + PersonalizationPage.HOME_PREFERENCE + ), + onSkipClick = { finish() }, + onSearchClick = { + val intent = SearchActivity.newIntent(this, Constants.InvokeSource.FEED_INTEREST_SELECTION, null, returnLink = true) + searchLauncher.launch(intent) + }, + onCompleteOnboardingClick = { + startActivity(ExploreFeedBuildingActivity.newIntent(this)) + finish() + }, + showError = { message -> + FeedbackUtil.showError(this, message) + } + ) + } + } + } + + companion object { + fun newIntent(context: Context): Intent { + return Intent(context, PersonalizationActivity::class.java) + } + } +} + +enum class PersonalizationPage { + CURIOSITY, + INTERESTS, + HOME_PREFERENCE +} diff --git a/app/src/main/java/org/wikipedia/feed/personalization/PersonalizationScreen.kt b/app/src/main/java/org/wikipedia/feed/personalization/PersonalizationScreen.kt new file mode 100644 index 00000000000..904127220ba --- /dev/null +++ b/app/src/main/java/org/wikipedia/feed/personalization/PersonalizationScreen.kt @@ -0,0 +1,183 @@ +package org.wikipedia.feed.personalization + +import androidx.compose.foundation.background +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.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import org.wikipedia.R +import org.wikipedia.compose.components.PageIndicator +import org.wikipedia.compose.theme.WikipediaTheme +import org.wikipedia.feed.personalization.homepreference.HomePreferenceScreen +import org.wikipedia.feed.personalization.interest.InterestOnboardingScreen + +// TODO: probably renaming the screen name +@Composable +fun PersonalizationScreen( + modifier: Modifier = Modifier, + screens: List, + onSkipClick: () -> Unit, + onCompleteOnboardingClick: () -> Unit, + onSearchClick: () -> Unit, + showError: (Throwable) -> Unit, + viewModel: PersonalizationViewModel +) { + val coroutineScope = rememberCoroutineScope() + val interestUiState = viewModel.interestUiState.collectAsState() + val feedPreferenceUiState = viewModel.feedPreferenceUiState.collectAsState() + val pagerState = rememberPagerState(pageCount = { screens.size }) + + LaunchedEffect(pagerState.currentPage) { + viewModel.onPageChanged(screens[pagerState.currentPage]) + } + + Scaffold( + bottomBar = { + OnboardingBottomBar( + pagerState = pagerState, + onNavigationRightClick = { + coroutineScope.launch { + if (pagerState.currentPage < pagerState.pageCount - 1) { + pagerState.animateScrollToPage(pagerState.currentPage + 1) + } else { + onCompleteOnboardingClick() + } + } + }, + onSkipClick = onSkipClick + ) + }, + containerColor = WikipediaTheme.colors.paperColor + ) { paddingValues -> + Column( + modifier = modifier + .padding(paddingValues) + ) { + HorizontalPager( + state = pagerState + ) { pageIndex -> + when (screens[pageIndex]) { + PersonalizationPage.CURIOSITY -> { + OnboardingCuriosityScreen(modifier = Modifier.fillMaxWidth()) + } + PersonalizationPage.INTERESTS -> { + InterestOnboardingScreen( + modifier = Modifier + .fillMaxSize() + .background(WikipediaTheme.colors.paperColor) + .padding(top = 40.dp), + topicsState = interestUiState.value.topicsState, + articlesState = interestUiState.value.articlesState, + totalSelectedCount = interestUiState.value.totalSelectedCount, + onTopicSelected = { + viewModel.onTopicSelected(it) + }, + onItemClick = { + viewModel.toggleArticleSelection(it) + }, + onSearchClick = onSearchClick, + onDeselectAllClick = { + viewModel.deselectAllArticles() + }, + retryLoading = { + viewModel.retryInterestsLoading() + }, + showError = showError + ) + } + PersonalizationPage.HOME_PREFERENCE -> { + HomePreferenceScreen( + modifier = Modifier + .fillMaxSize() + .background(WikipediaTheme.colors.paperColor) + .padding(top = 40.dp), + selectedType = feedPreferenceUiState.value.selectedType, + communityContentState = feedPreferenceUiState.value.communityState, + personalizedContentState = feedPreferenceUiState.value.personalizedState, + onTypeSelected = { viewModel.onFeedPreferenceTypeSelected(it) }, + onRetryClick = { viewModel.retryFeedPreferenceLoading(it) } + ) + } + } + } + } + } +} + +@Composable +fun OnboardingBottomBar( + pagerState: PagerState, + onNavigationRightClick: () -> Unit, + onSkipClick: () -> Unit, +) { + Column { + HorizontalDivider( + modifier = Modifier + .height(1.dp) + .fillMaxWidth(), + color = WikipediaTheme.colors.borderColor + ) + + Row( + modifier = Modifier + .navigationBarsPadding(), + verticalAlignment = Alignment.CenterVertically, + ) { + TextButton( + onClick = { onSkipClick() }, + modifier = Modifier + .wrapContentWidth(Alignment.Start) + .wrapContentHeight(Alignment.CenterVertically) + ) { + Text( + text = stringResource(id = R.string.onboarding_skip), + color = WikipediaTheme.colors.placeholderColor + ) + } + + PageIndicator( + modifier = Modifier + .weight(1f) + .wrapContentHeight(Alignment.CenterVertically), + pagerState = pagerState + ) + + IconButton( + onClick = { onNavigationRightClick() }, + modifier = Modifier + .wrapContentWidth(Alignment.End) + .wrapContentHeight(Alignment.CenterVertically) + ) { + Icon( + painter = painterResource(id = R.drawable.ic_arrow_forward_black_24dp), + contentDescription = stringResource(id = R.string.onboarding_next), + tint = WikipediaTheme.colors.progressiveColor + ) + } + } + } +} diff --git a/app/src/main/java/org/wikipedia/feed/personalization/PersonalizationViewModel.kt b/app/src/main/java/org/wikipedia/feed/personalization/PersonalizationViewModel.kt new file mode 100644 index 00000000000..b44c55098f0 --- /dev/null +++ b/app/src/main/java/org/wikipedia/feed/personalization/PersonalizationViewModel.kt @@ -0,0 +1,403 @@ +package org.wikipedia.feed.personalization + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.wikipedia.WikipediaApp +import org.wikipedia.database.AppDatabase +import org.wikipedia.feed.personalization.homepreference.HomeContentState +import org.wikipedia.feed.personalization.homepreference.HomePreferenceContent +import org.wikipedia.feed.personalization.homepreference.HomePreferenceRepository +import org.wikipedia.feed.personalization.homepreference.HomePreferenceType +import org.wikipedia.feed.personalization.homepreference.HomePreferenceUiState +import org.wikipedia.feed.personalization.interest.ArticlesState +import org.wikipedia.feed.personalization.interest.InterestSelectionRepository +import org.wikipedia.feed.personalization.interest.InterestUiState +import org.wikipedia.feed.personalization.interest.OnboardingTopic +import org.wikipedia.feed.personalization.interest.TopicsState +import org.wikipedia.page.PageTitle +import org.wikipedia.util.log.L + +// this is a raw, flat, internal representation of ALL state +// needed across the personalization flow (interest and feed preference) +// this enables SINGLE SOURCE OF TRUTH — one place to update, no risk of states going out of sync +// DERIVED UI STATES — each screen gets its own UI state derived from a function like toInterestUIState() +// instead of maintaining separate StateFlows per screen or one giant combined UI state +private data class PersonalizedViewModelState( + // Interest screen + val topics: List = emptyList(), + val topicsLoading: Boolean = false, + val topicsError: Throwable? = null, + val articles: List = emptyList(), + val articlesLoading: Boolean = false, + val articlesError: Throwable? = null, + val selectedArticles: Set = emptySet(), + val selectedTopics: List = emptyList(), + val topicPreviewContent: Map> = emptyMap(), + // Feed preference screen properties + val homePreferenceType: HomePreferenceType = HomePreferenceType.COMMUNITY, + val communityContent: List = emptyList(), + val communityLoading: Boolean = false, + val communityError: Throwable? = null, + val personalizedContent: List = emptyList(), + val personalizedLoading: Boolean = false, + val personalizedError: Throwable? = null +) { + fun toInterestUiState(): InterestUiState { + return InterestUiState( + topicsState = when { + topicsLoading -> TopicsState.Loading + topicsError != null -> TopicsState.Error(topicsError) + + else -> TopicsState.Success( + topics = topics.map { + it.copy(isSelected = selectedTopics.any { selected -> selected.topicId == it.topicId }) + } + ) + }, + articlesState = when { + articlesLoading -> ArticlesState.Loading + articlesError != null -> ArticlesState.Error(articlesError) + + else -> ArticlesState.Success( + articles = articles, + selectedArticles = selectedArticles + ) + }, + totalSelectedCount = selectedTopics.size + selectedArticles.size + ) + } + + fun toFeedPreferenceUiState(): HomePreferenceUiState { + return HomePreferenceUiState( + selectedType = homePreferenceType, + communityState = when { + communityLoading -> HomeContentState.Loading + communityError != null -> HomeContentState.Error(communityError) + else -> HomeContentState.Success(communityContent) + }, + personalizedState = when { + personalizedLoading -> HomeContentState.Loading + personalizedError != null -> HomeContentState.Error(personalizedError) + personalizedContent.isEmpty() -> HomeContentState.Empty + else -> HomeContentState.Success(personalizedContent) + } + ) + } +} + +class PersonalizationViewModel( + private val interestSelectionRepository: InterestSelectionRepository, + private val homePreferenceRepository: HomePreferenceRepository +) : ViewModel() { + // Single source of truth for all personalization state, can be easily extended to include feed preference and language selection states as well + private val state = MutableStateFlow(PersonalizedViewModelState()) + private var articlesJob: Job? = null + + // Each screen observes only its own derived UI state + // runs automatically when any part of the raw state changes + val interestUiState = state + .map { it.toInterestUiState() } + .stateIn( + scope = viewModelScope, + started = SharingStarted.Eagerly, + initialValue = state.value.toInterestUiState() + ) + + val feedPreferenceUiState = state + .map { it.toFeedPreferenceUiState() } + .stateIn( + scope = viewModelScope, + started = SharingStarted.Eagerly, + initialValue = state.value.toFeedPreferenceUiState() + ) + + fun onPageChanged(screen: PersonalizationPage) { + when (screen) { + PersonalizationPage.INTERESTS -> loadInterestSelectionScreen() + PersonalizationPage.HOME_PREFERENCE -> loadFeedPreferenceScreen() + else -> {} + } + } + + private fun loadInterestSelectionScreen() { + viewModelScope.launch( CoroutineExceptionHandler { _, throwable -> + L.e(throwable) + }) { + loadTopics() + initialize() + } + } + + private fun loadFeedPreferenceScreen() { + if (state.value.communityContent.isEmpty()) { + loadCommunityPreviewContent() + } + loadPersonalizedPreviewContent() + } + + private fun loadCommunityPreviewContent() { + viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> + state.update { it.copy(communityLoading = false, communityError = throwable) } + L.e(throwable) + }) { + state.update { it.copy(communityLoading = true, communityError = null) } + val communityContent = homePreferenceRepository.getCommunityPreviewContent() + state.update { it.copy(communityContent = communityContent, communityLoading = false) } + } + } + + private fun loadPersonalizedPreviewContent() { + viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> + state.update { it.copy(personalizedLoading = false, personalizedError = throwable) } + L.e(throwable) + }) { + state.update { it.copy(personalizedLoading = true, personalizedError = null) } + val personalizedContent = homePreferenceRepository.getPersonalizedPreviewContent( + selectedArticles = state.value.selectedArticles, + contentByTopic = state.value.topicPreviewContent + ) + state.update { it.copy(personalizedContent = personalizedContent, personalizedLoading = false) } + } + } + + private suspend fun loadTopics() { + if (state.value.topics.isNotEmpty()) return + + runCatching { + state.update { it.copy(topicsLoading = true, topicsError = null) } + + val langCode = interestSelectionRepository.wikiSite.languageCode + val topics = interestSelectionRepository.getTopics(langCode) + + state.update { it.copy(topics = topics, topicsLoading = false) } + }.onFailure { throwable -> + state.update { it.copy(topicsLoading = false, topicsError = throwable) } + } + } + + private suspend fun initialize() { + runCatching { + val langCode = interestSelectionRepository.wikiSite.languageCode + // check db for persisted interest (topic and articles) data + val persistedTopics = interestSelectionRepository.getPersistedTopics(langCode) + val persistedArticles = interestSelectionRepository.getPersistedArticles(langCode) + + val hasPersistedData = persistedTopics.isNotEmpty() || persistedArticles.isNotEmpty() + if (!hasPersistedData && state.value.articles.isEmpty()) { + loadInitialArticles() + return@runCatching + } + + // restore selections + state.update { current -> + current.copy( + selectedTopics = persistedTopics, + selectedArticles = persistedArticles.toSet() + ) + } + + val lastTopic = persistedTopics.lastOrNull() + if (lastTopic != null) { + loadArticlesByTopic(topic = lastTopic) + } else { + loadInitialArticles() + } + }.onFailure { throwable -> + state.update { it.copy(articlesLoading = false, articlesError = throwable) } + } + } + + private fun loadInitialArticles() { + if (state.value.articles.isNotEmpty()) return + articlesJob?.cancel() + + articlesJob = viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> + state.update { it.copy(articlesLoading = false, articlesError = throwable) } + }) { + state.update { it.copy(articlesLoading = true, articlesError = null) } + + val articles = interestSelectionRepository.loadInitialArticles() + state.update { current -> + val newArticles = (current.selectedArticles + articles).distinct() + current.copy( + articles = newArticles, + articlesLoading = false + ) + } + } + } + + private fun loadArticlesByTopic(topic: OnboardingTopic) { + if (state.value.articles.isNotEmpty()) return + articlesJob?.cancel() + + articlesJob = viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> + state.update { it.copy(articlesLoading = false, articlesError = throwable) } + }) { + state.update { it.copy(articlesLoading = true, articlesError = null) } + + val articles = interestSelectionRepository.getArticlesByTopic(topic.queryTopicId) + val previewContent = HomePreferenceContent.fromPageTitles(pageTitles = articles, topic = topic) + state.update { current -> + val newArticles = (current.selectedArticles.toList() + articles).distinct() + current.copy(articles = newArticles, topicPreviewContent = current.topicPreviewContent + (topic.topicId to previewContent), articlesLoading = false) + } + } + } + + // as we have a single state it becomes easier to update and control the state + fun onTopicSelected(topic: OnboardingTopic) { + val lang = interestSelectionRepository.wikiSite.languageCode + + // When a category is selected, we want to reset the articles state and load articles for the selected category + viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> + state.update { it.copy(topicsError = throwable) } + }) { + val currentTopics = state.value.selectedTopics + val isSelected = currentTopics.any { selected -> selected.topicId == topic.topicId } + + val selectedTopics = if (isSelected) { + currentTopics.filter { it.topicId != topic.topicId } + } else { + currentTopics + topic + } + + if (isSelected) { + interestSelectionRepository.deleteTopic(topic, lang) + } else { + interestSelectionRepository.saveTopic(topic, lang) + } + + state.update { current -> + current.copy( + selectedTopics = selectedTopics, + topicPreviewContent = if (isSelected) { + current.topicPreviewContent - topic.topicId + } else { + current.topicPreviewContent + }, + articles = emptyList(), + articlesError = null + ) + } + + val lastSelectedTopic = selectedTopics.lastOrNull() + if (lastSelectedTopic == null) loadInitialArticles() else loadArticlesByTopic(topic = lastSelectedTopic) + } + } + + fun addArticleFromSearch(title: PageTitle) { + viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> + state.update { it.copy(articlesError = throwable) } + } + ) { + interestSelectionRepository.saveArticle(title, interestSelectionRepository.wikiSite.languageCode, null) + state.update { + val newItems = listOf(title) + it.articles + val newSelection = it.selectedArticles + title + it.copy(articles = newItems, selectedArticles = newSelection) + } + } + } + + fun toggleArticleSelection(title: PageTitle) { + val lang = interestSelectionRepository.wikiSite.languageCode + + viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> + state.update { it.copy(articlesError = throwable) } + }) { + val current = state.value + val isSelected = current.selectedArticles.contains(title) + val currentSelectedTopic = current.selectedTopics.lastOrNull() + + if (isSelected) { + interestSelectionRepository.deleteArticle(title, lang, currentSelectedTopic) + } else { + interestSelectionRepository.saveArticle(title, lang, currentSelectedTopic) + } + + state.update { currentState -> + currentState.copy( + selectedArticles = if (isSelected) { + currentState.selectedArticles - title + } else { + currentState.selectedArticles + title + } + ) + } + } + } + + fun deselectAllArticles() { + viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> + state.update { it.copy(articlesError = throwable) } + } + ) { + interestSelectionRepository.deleteAllInterests() + + state.update { + it.copy( + selectedArticles = emptySet(), + selectedTopics = emptyList(), + topicPreviewContent = emptyMap(), + articlesLoading = false, + articlesError = null + ) + } + } + } + + fun retryInterestsLoading() { + val last = state.value.selectedTopics.lastOrNull() + if (last != null) { + loadArticlesByTopic(topic = last) + } else { + loadInitialArticles() + } + } + + fun onFeedPreferenceTypeSelected(type: HomePreferenceType) { + homePreferenceRepository.savePreference(type) + state.update { it.copy(homePreferenceType = type) } + } + + fun retryFeedPreferenceLoading(type: HomePreferenceType) { + when (type) { + HomePreferenceType.COMMUNITY -> loadCommunityPreviewContent() + HomePreferenceType.PERSONALIZED -> loadPersonalizedPreviewContent() + } + } + + companion object { + val Factory = viewModelFactory { + initializer { + val appDatabase = AppDatabase.instance + val instance = WikipediaApp.instance + PersonalizationViewModel( + interestSelectionRepository = InterestSelectionRepository( + interestTopicDao = appDatabase.topicInterestDao(), + interestArticleDao = appDatabase.articleInterestDao(), + historyEntryWithImageDao = appDatabase.historyEntryWithImageDao(), + readingListPageDao = appDatabase.readingListPageDao(), + wikiSite = instance.wikiSite + ), + homePreferenceRepository = HomePreferenceRepository( + context = instance, + historyEntryWithImageDao = AppDatabase.instance.historyEntryWithImageDao(), + wikiSite = instance.wikiSite + ) + ) + } + } + } +} diff --git a/app/src/main/java/org/wikipedia/feed/personalization/db/dao/InterestArticleDao.kt b/app/src/main/java/org/wikipedia/feed/personalization/db/dao/InterestArticleDao.kt new file mode 100644 index 00000000000..2350e548c61 --- /dev/null +++ b/app/src/main/java/org/wikipedia/feed/personalization/db/dao/InterestArticleDao.kt @@ -0,0 +1,27 @@ +package org.wikipedia.feed.personalization.db.dao + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import org.wikipedia.feed.personalization.db.entity.InterestArticle +import org.wikipedia.page.Namespace + +@Dao +interface InterestArticleDao { + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insert(interestArticle: InterestArticle) + + @Delete + suspend fun delete(interestArticle: InterestArticle) + + @Query("DELETE FROM InterestArticle") + suspend fun deleteAll() + + @Query("SELECT * FROM InterestArticle WHERE lang = :lang") + suspend fun getAll(lang: String): List + + @Query("UPDATE InterestArticle SET topicId = :newTopicId WHERE apiTitle = :apiTitle AND lang = :lang AND namespace = :namespace") + suspend fun updateTopic(newTopicId: String, apiTitle: String, lang: String, namespace: Namespace) +} diff --git a/app/src/main/java/org/wikipedia/feed/personalization/db/dao/InterestTopicDao.kt b/app/src/main/java/org/wikipedia/feed/personalization/db/dao/InterestTopicDao.kt new file mode 100644 index 00000000000..9afd7c1fb02 --- /dev/null +++ b/app/src/main/java/org/wikipedia/feed/personalization/db/dao/InterestTopicDao.kt @@ -0,0 +1,23 @@ +package org.wikipedia.feed.personalization.db.dao + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import org.wikipedia.feed.personalization.db.entity.InterestTopic + +@Dao +interface InterestTopicDao { + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insert(interestTopic: InterestTopic) + + @Delete + suspend fun delete(interestTopic: InterestTopic) + + @Query("DELETE FROM InterestTopic") + suspend fun deleteAll() + + @Query("SELECT * FROM InterestTopic WHERE lang = :lang") + suspend fun getAll(lang: String): List +} diff --git a/app/src/main/java/org/wikipedia/feed/personalization/db/entity/InterestArticle.kt b/app/src/main/java/org/wikipedia/feed/personalization/db/entity/InterestArticle.kt new file mode 100644 index 00000000000..8b398c1ba84 --- /dev/null +++ b/app/src/main/java/org/wikipedia/feed/personalization/db/entity/InterestArticle.kt @@ -0,0 +1,30 @@ +package org.wikipedia.feed.personalization.db.entity + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import org.wikipedia.page.Namespace + +@Entity( + primaryKeys = ["apiTitle", "lang", "namespace"], + foreignKeys = [ + ForeignKey( + entity = InterestTopic::class, + parentColumns = ["topicId", "lang"], // primary key in the parent entity + childColumns = ["topicId", "topicLang"], // foreign key in this entity which references the primary key in parent entity + onDelete = ForeignKey.SET_NULL, // when a topic is deleted, the foreign key in this entity will be set to null to not delete the article interest but just disassociate it from the deleted topic + ) + ], + indices = [Index(value = ["topicId", "topicLang"])] // index for the foreign key columns to improve query performance especially for cascade operations +) +data class InterestArticle( + val apiTitle: String, + val lang: String, + val namespace: Namespace, + val displayTitle: String, + val description: String, + val thumbUrl: String, + // foreign key referencing the topicId, lang in TopicInterest + val topicId: String? = null, + val topicLang: String? = null +) diff --git a/app/src/main/java/org/wikipedia/feed/personalization/db/entity/InterestTopic.kt b/app/src/main/java/org/wikipedia/feed/personalization/db/entity/InterestTopic.kt new file mode 100644 index 00000000000..70487353a9f --- /dev/null +++ b/app/src/main/java/org/wikipedia/feed/personalization/db/entity/InterestTopic.kt @@ -0,0 +1,13 @@ +package org.wikipedia.feed.personalization.db.entity + +import androidx.room.Entity + +@Entity( + primaryKeys = ["topicId", "lang"] +) +data class InterestTopic( + val topicId: String, + val lang: String, + val topicLabel: String, + val queryTopicId: String +) diff --git a/app/src/main/java/org/wikipedia/feed/personalization/homepreference/HomePreferenceRepository.kt b/app/src/main/java/org/wikipedia/feed/personalization/homepreference/HomePreferenceRepository.kt new file mode 100644 index 00000000000..b480b1019cc --- /dev/null +++ b/app/src/main/java/org/wikipedia/feed/personalization/homepreference/HomePreferenceRepository.kt @@ -0,0 +1,117 @@ +package org.wikipedia.feed.personalization.homepreference + +import android.content.Context +import org.wikipedia.R +import org.wikipedia.dataclient.ServiceFactory +import org.wikipedia.dataclient.WikiSite +import org.wikipedia.extensions.getString +import org.wikipedia.history.db.HistoryEntryWithImageDao +import org.wikipedia.page.PageTitle +import org.wikipedia.settings.Prefs +import org.wikipedia.util.StringUtil +import java.time.LocalDate + +class HomePreferenceRepository( + private val context: Context, + private val historyEntryWithImageDao: HistoryEntryWithImageDao, + private val wikiSite: WikiSite +) { + suspend fun getCommunityPreviewContent(): List { + val currentDate = LocalDate.now() + + val response = ServiceFactory.getRest(wikiSite).getFeedFeatured( + year = currentDate.year.toString(), + month = "%02d".format(currentDate.monthValue), + day = "%02d".format(currentDate.dayOfMonth), + lang = wikiSite.languageCode + ) + + val featuredArticle = response.tfa?.let { article -> + HomePreferenceContent( + title = article.displayTitle, + description = article.description, + imageUrl = article.thumbnailUrl, + tag = context.getString(wikiSite.languageCode, R.string.view_featured_article_card_title) + ) + } + + val pictureOfTheDay = response.potd?.let { potd -> + HomePreferenceContent( + title = null, + description = potd.description.text, + imageUrl = potd.thumbnailUrl, + tag = context.getString(wikiSite.languageCode, R.string.view_featured_image_card_title) + ) + } + + val topNewsItem = response.news?.firstOrNull()?.let { newsItem -> + HomePreferenceContent( + title = null, + description = StringUtil.removeHTMLTags(newsItem.story), + imageUrl = newsItem.thumbUrl(), + tag = context.getString(wikiSite.languageCode, R.string.view_card_news_title) + ) + } + + return listOfNotNull( + featuredArticle, + pictureOfTheDay, + topNewsItem + ) + } + + suspend fun getPersonalizedPreviewContent( + selectedArticles: Set, + contentByTopic: Map>, + ): List { + if (contentByTopic.isNotEmpty()) { + return sampleAcrossTopics(contentByTopic = contentByTopic) + } + + if (selectedArticles.isNotEmpty()) { + return fetchMoreLike(seeds = selectedArticles.map { it.prefixedText }) + } + + val recentHistoryEntries = historyEntryWithImageDao.getMostRecentEntriesWithImage(3) + if (recentHistoryEntries.size >= 3) { + return fetchMoreLike(seeds = recentHistoryEntries.map { it.apiTitle }) + } + + return listOf() + } + + // has count logic for cases where user has selected less than 3 topics + // as we need 3 articles to show in the preview, we need to distribute them across the selected topics + private fun sampleAcrossTopics( + contentByTopic: Map>, + totalCount: Int = 3, + ): List { + val topicIds = contentByTopic.keys.toList().reversed() + + val baseLimit = totalCount / topicIds.size + val remainder = totalCount % topicIds.size + + return topicIds.flatMapIndexed { index, topic -> + val count = baseLimit + if (index < remainder) 1 else 0 + contentByTopic[topic].orEmpty().take(count) + } + } + + private suspend fun fetchMoreLike(seeds: List): List { + if (seeds.isEmpty()) return emptyList() + val moreLikeSearchTerm = "morelike:${seeds.take(3).joinToString("|")}" + val response = ServiceFactory.get(wikiSite).searchMoreLike(searchTerm = moreLikeSearchTerm, gsrLimit = 3, piLimit = 3) + return response.query?.pages?.map { page -> + HomePreferenceContent( + title = page.displayTitle(wikiSite.languageCode), + description = page.description, + imageUrl = page.thumbUrl(), + tag = null + ) + } ?: emptyList() + } + + fun savePreference(preferenceType: HomePreferenceType) { + Prefs.homePreferenceSelection = preferenceType + } +} diff --git a/app/src/main/java/org/wikipedia/feed/personalization/homepreference/HomePreferenceScreen.kt b/app/src/main/java/org/wikipedia/feed/personalization/homepreference/HomePreferenceScreen.kt new file mode 100644 index 00000000000..a5e5a76471f --- /dev/null +++ b/app/src/main/java/org/wikipedia/feed/personalization/homepreference/HomePreferenceScreen.kt @@ -0,0 +1,460 @@ +package org.wikipedia.feed.personalization.homepreference + +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +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.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.RadioButtonDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.painter.BrushPainter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import org.wikipedia.R +import org.wikipedia.compose.components.HtmlText +import org.wikipedia.compose.components.WikiCard +import org.wikipedia.compose.components.error.WikiErrorClickEvents +import org.wikipedia.compose.components.error.WikiErrorView +import org.wikipedia.compose.extensions.shimmerEffect +import org.wikipedia.compose.theme.BaseTheme +import org.wikipedia.compose.theme.WikipediaTheme +import org.wikipedia.theme.Theme +import org.wikipedia.views.imageservice.ImageService + +@Composable +fun HomePreferenceScreen( + modifier: Modifier = Modifier, + selectedType: HomePreferenceType, + communityContentState: HomeContentState, + personalizedContentState: HomeContentState, + onTypeSelected: (HomePreferenceType) -> Unit, + onRetryClick: (HomePreferenceType) -> Unit +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + modifier = Modifier.padding(horizontal = 16.dp), + text = stringResource(id = R.string.explore_feed_preference_selection_screen_title), + style = MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.Medium + ), + color = WikipediaTheme.colors.primaryColor + ) + + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(24.dp), + ) { + item { + HomePreferenceSection( + state = communityContentState, + isSelected = selectedType == HomePreferenceType.COMMUNITY, + homePreferenceType = HomePreferenceType.COMMUNITY, + onSelected = onTypeSelected, + onRetryClick = onRetryClick + ) + } + item { + HomePreferenceSection( + state = personalizedContentState, + isSelected = selectedType == HomePreferenceType.PERSONALIZED, + homePreferenceType = HomePreferenceType.PERSONALIZED, + onSelected = onTypeSelected, + onRetryClick = onRetryClick + ) + } + } + } +} + +@Composable +fun HomePreferenceSection( + state: HomeContentState, + isSelected: Boolean, + homePreferenceType: HomePreferenceType, + onRetryClick: (HomePreferenceType) -> Unit, + onSelected: (HomePreferenceType) -> Unit +) { + val transition = rememberInfiniteTransition(label = "homePreferenceShimmerTransition") + val isPersonalizedContentDisabled = state is HomeContentState.Empty + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp) + .clickable(onClick = { if (!isPersonalizedContentDisabled) onSelected(homePreferenceType) }), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = isSelected, + onClick = { onSelected(homePreferenceType) }, + enabled = !isPersonalizedContentDisabled, + colors = RadioButtonDefaults.colors( + selectedColor = WikipediaTheme.colors.primaryColor, + unselectedColor = WikipediaTheme.colors.primaryColor, + disabledUnselectedColor = WikipediaTheme.colors.inactiveColor, + disabledSelectedColor = WikipediaTheme.colors.inactiveColor + ) + ) + Text( + text = stringResource(homePreferenceType.titleRes), + style = MaterialTheme.typography.bodyLarge.copy( + fontWeight = if (isPersonalizedContentDisabled) FontWeight.Normal else FontWeight.Medium + ), + color = if (isPersonalizedContentDisabled) WikipediaTheme.colors.inactiveColor else + WikipediaTheme.colors.primaryColor + ) + } + + LazyRow( + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(horizontal = 24.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + when (state) { + is HomeContentState.Error -> { + item { + Box( + modifier = Modifier.fillParentMaxWidth(), + contentAlignment = Alignment.Center + ) { + WikiErrorView( + caught = state.message, + errorClickEvents = WikiErrorClickEvents( + retryClickListener = { onRetryClick(homePreferenceType) } + ) + ) + } + } + } + + HomeContentState.Loading -> { + items(3) { + Box( + modifier = Modifier + .width(185.dp) + .height(230.dp) + .clip(RoundedCornerShape(size = 12.dp)) + .shimmerEffect(transition = transition) + ) + } + } + + HomeContentState.Empty -> { + item { + Text( + modifier = Modifier.fillParentMaxWidth(), + text = stringResource(R.string.explore_feed_personalized_preference_empty_state_text), + style = MaterialTheme.typography.bodyLarge, + color = WikipediaTheme.colors.primaryColor + ) + } + } + + is HomeContentState.Success -> { + items(state.content) { content -> + HomePreferenceArticleCard( + content = content, + homePreferenceType = homePreferenceType + ) + } + } + } + } + } +} + +@Composable +fun HomePreferenceArticleCard( + modifier: Modifier = Modifier, + homePreferenceType: HomePreferenceType, + content: HomePreferenceContent +) { + WikiCard( + modifier = modifier + .width(185.dp) + .height(230.dp), + elevation = 0.dp, + border = BorderStroke(width = 1.dp, color = WikipediaTheme.colors.borderColor) + ) { + Column(modifier = Modifier.fillMaxHeight()) { + Box( + modifier = Modifier + .height(108.dp) + ) { + val request = ImageService.getRequest( + LocalContext.current, + url = content.imageUrl, + detectFace = true + ) + AsyncImage( + model = request, + placeholder = BrushPainter(SolidColor(WikipediaTheme.colors.borderColor)), + error = BrushPainter(SolidColor(WikipediaTheme.colors.borderColor)), + contentScale = ContentScale.Crop, + contentDescription = null, + modifier = Modifier + .fillMaxWidth() + .height(108.dp) + ) + if (!content.tag.isNullOrEmpty()) { + ArticleCardTag( + modifier = Modifier + .align(Alignment.BottomStart) + .padding(8.dp) + .background( + when (homePreferenceType) { + HomePreferenceType.COMMUNITY -> WikipediaTheme.colors.progressiveColor + HomePreferenceType.PERSONALIZED -> WikipediaTheme.colors.successColor + }, shape = RoundedCornerShape(8.dp) + ) + .padding(horizontal = 12.dp, vertical = 4.dp), + text = content.tag + ) + } + } + Column( + modifier = Modifier + .padding(16.dp) + .weight(1f) + ) { + if (!content.title.isNullOrEmpty()) { + HtmlText( + text = content.title, + style = MaterialTheme.typography.bodyMedium.copy( + fontWeight = FontWeight.Medium + ), + maxLines = 2, + color = WikipediaTheme.colors.primaryColor, + ) + } + + if (!content.description.isNullOrEmpty()) { + HtmlText( + text = content.description, + style = MaterialTheme.typography.bodyMedium, + color = WikipediaTheme.colors.secondaryColor, + maxLines = if (!content.title.isNullOrEmpty()) 3 else Int.MAX_VALUE, + overflow = TextOverflow.Ellipsis + ) + } else { + Spacer(modifier = Modifier.weight(1f)) + } + } + } + } +} + +@Composable +fun ArticleCardTag( + text: String, + modifier: Modifier = Modifier +) { + Text( + modifier = modifier, + text = text, + style = MaterialTheme.typography.bodySmall.copy( + fontWeight = FontWeight.Medium + ), + color = WikipediaTheme.colors.backgroundColor + ) +} + +@Preview(showBackground = true) +@Composable +private fun HomePreferenceScreenPreview() { + BaseTheme( + currentTheme = Theme.LIGHT + ) { + HomePreferenceScreen( + modifier = Modifier + .fillMaxSize() + .background(WikipediaTheme.colors.paperColor) + .padding(top = 40.dp), + selectedType = HomePreferenceType.COMMUNITY, + communityContentState = HomeContentState.Success( + content = listOf( + HomePreferenceContent( + title = "Winter Paralympics", + description = "2026 Winter Olympics Multi-sport event in Italy", + imageUrl = "https://upload.wikimedia.org/wikipedia/en/thumb/8/80/Wikipedia-logo-v2.svg/120px-Wikipedia-logo-v2.svg.png", + tag = "In the news" + ), + HomePreferenceContent( + title = "Rosa Parks", + description = "American civil rights activist (1913–2005)", + imageUrl = "https://upload.wikimedia.org/wikipedia/en/thumb/8/80/Wikipedia-logo-v2.svg/120px-Wikipedia-logo-v2.svg.png", + tag = "Featured article" + ), + HomePreferenceContent( + title = "Rosa Parks", + description = "American civil rights activist (1913–2005)", + imageUrl = "https://upload.wikimedia.org/wikipedia/en/thumb/8/80/Wikipedia-logo-v2.svg/120px-Wikipedia-logo-v2.svg.png", + tag = "Featured article" + ) + ) + ), + personalizedContentState = HomeContentState.Success( + content = listOf( + HomePreferenceContent( + title = "Personalized Content", + description = "See content that’s personalized for you based on your reading history and interests.", + imageUrl = "https://upload.wikimedia.org/wikipedia/en/thumb/8/80/Wikipedia-logo-v2.svg/120px-Wikipedia-logo-v2.svg.png", + tag = "Personalized" + ) + ) + ), + onTypeSelected = {}, + onRetryClick = {} + ) + } +} + +@Preview(showBackground = true, fontScale = 1.5f, device = Devices.PIXEL_9) +@Composable +private fun HomePreferenceScreenScaledTextPreview() { + BaseTheme( + currentTheme = Theme.DARK + ) { + HomePreferenceScreen( + modifier = Modifier + .fillMaxSize() + .background(WikipediaTheme.colors.paperColor) + .padding(top = 40.dp), + selectedType = HomePreferenceType.COMMUNITY, + communityContentState = HomeContentState.Success( + content = listOf( + HomePreferenceContent( + title = "Winter Paralympics", + description = "2026 Winter Olympics Multi-sport event in Italy", + imageUrl = "https://upload.wikimedia.org/wikipedia/en/thumb/8/80/Wikipedia-logo-v2.svg/120px-Wikipedia-logo-v2.svg.png", + tag = "In the news" + ), + HomePreferenceContent( + title = "Rosa Parks", + description = "American civil rights activist (1913–2005)", + imageUrl = "https://upload.wikimedia.org/wikipedia/en/thumb/8/80/Wikipedia-logo-v2.svg/120px-Wikipedia-logo-v2.svg.png", + tag = "Featured article" + ), + HomePreferenceContent( + title = "Rosa Parks", + description = "American civil rights activist (1913–2005)", + imageUrl = "https://upload.wikimedia.org/wikipedia/en/thumb/8/80/Wikipedia-logo-v2.svg/120px-Wikipedia-logo-v2.svg.png", + tag = "Featured article" + ) + ) + ), + personalizedContentState = HomeContentState.Success( + content = listOf( + HomePreferenceContent( + title = "Post's lattice", + description = "Lattice in universal algebra", + imageUrl = "https://upload.wikimedia.org/wikipedia/en/thumb/8/80/Wikipedia-logo-v2.svg/120px-Wikipedia-logo-v2.svg.png", + tag = "Logic" + ), + HomePreferenceContent( + title = "Ranunculaceae", + description = "Family of eudicot flowering plants", + imageUrl = "https://upload.wikimedia.org/wikipedia/en/thumb/8/80/Wikipedia-logo-v2.svg/120px-Wikipedia-logo-v2.svg.png", + tag = "Nature" + ) + ) + ), + onTypeSelected = {}, + onRetryClick = {} + ) + } +} + +@Preview(showBackground = true, device = Devices.PIXEL_9) +@Composable +private fun HomePreferenceScreenLoadingPreview() { + BaseTheme( + currentTheme = Theme.LIGHT + ) { + HomePreferenceScreen( + modifier = Modifier + .fillMaxSize() + .background(WikipediaTheme.colors.paperColor) + .padding(top = 40.dp), + selectedType = HomePreferenceType.COMMUNITY, + communityContentState = HomeContentState.Loading, + personalizedContentState = HomeContentState.Loading, + onTypeSelected = {}, + onRetryClick = {} + ) + } +} + +@Preview(showBackground = true, device = Devices.PIXEL_9) +@Composable +private fun HomePreferenceScreenErrorPreview() { + BaseTheme( + currentTheme = Theme.LIGHT + ) { + HomePreferenceScreen( + modifier = Modifier + .fillMaxSize() + .background(WikipediaTheme.colors.paperColor) + .padding(top = 40.dp), + selectedType = HomePreferenceType.COMMUNITY, + communityContentState = HomeContentState.Error(Throwable("Failed to load community content")), + personalizedContentState = HomeContentState.Error(Throwable("Failed to load personalized content")), + onTypeSelected = {}, + onRetryClick = {} + ) + } +} + +@Preview(showBackground = true, device = Devices.PIXEL_9) +@Composable +private fun HomePreferenceScreenEmptyPersonalizedContentPreview() { + BaseTheme( + currentTheme = Theme.LIGHT + ) { + HomePreferenceScreen( + modifier = Modifier + .fillMaxSize() + .background(WikipediaTheme.colors.paperColor) + .padding(top = 40.dp), + selectedType = HomePreferenceType.COMMUNITY, + communityContentState = HomeContentState.Loading, + personalizedContentState = HomeContentState.Empty, + onTypeSelected = {}, + onRetryClick = {} + ) + } +} diff --git a/app/src/main/java/org/wikipedia/feed/personalization/homepreference/HomePreferenceState.kt b/app/src/main/java/org/wikipedia/feed/personalization/homepreference/HomePreferenceState.kt new file mode 100644 index 00000000000..48f78aef93e --- /dev/null +++ b/app/src/main/java/org/wikipedia/feed/personalization/homepreference/HomePreferenceState.kt @@ -0,0 +1,43 @@ +package org.wikipedia.feed.personalization.homepreference + +import org.wikipedia.R +import org.wikipedia.feed.personalization.interest.OnboardingTopic +import org.wikipedia.page.PageTitle + +enum class HomePreferenceType(val titleRes: Int) { + COMMUNITY(R.string.explore_feed_preference_community_content_title), + PERSONALIZED(R.string.explore_feed_preference_personalized_content_title) +} + +data class HomePreferenceContent ( + val title: String?, + val description: String?, + val imageUrl: String?, + val tag: String? +) { + companion object { + fun fromPageTitles(pageTitles: List, topic: OnboardingTopic): List { + return pageTitles.map { page -> + HomePreferenceContent( + title = page.displayText, + description = page.description, + imageUrl = page.thumbUrl, + tag = topic.displayTitle + ) + } + } + } +} + +sealed interface HomeContentState { + data object Loading : HomeContentState + data object Empty : HomeContentState + data class Success(val content: List) : HomeContentState + data class Error(val message: Throwable) : HomeContentState +} + +data class HomePreferenceUiState( + val selectedType: HomePreferenceType = HomePreferenceType.COMMUNITY, + val communityState: HomeContentState = HomeContentState.Loading, + val personalizedState: HomeContentState = HomeContentState.Loading +) diff --git a/app/src/main/java/org/wikipedia/feed/personalization/interest/InterestSelectionRepository.kt b/app/src/main/java/org/wikipedia/feed/personalization/interest/InterestSelectionRepository.kt new file mode 100644 index 00000000000..ee5b3bc532e --- /dev/null +++ b/app/src/main/java/org/wikipedia/feed/personalization/interest/InterestSelectionRepository.kt @@ -0,0 +1,177 @@ +package org.wikipedia.feed.personalization.interest + +import androidx.room.Transaction +import org.wikipedia.dataclient.ServiceFactory +import org.wikipedia.dataclient.WikiSite +import org.wikipedia.feed.personalization.db.dao.InterestArticleDao +import org.wikipedia.feed.personalization.db.dao.InterestTopicDao +import org.wikipedia.feed.personalization.db.entity.InterestArticle +import org.wikipedia.feed.personalization.db.entity.InterestTopic +import org.wikipedia.feed.personalization.topics.OnboardingTopics +import org.wikipedia.history.db.HistoryEntryWithImageDao +import org.wikipedia.page.Namespace +import org.wikipedia.page.PageTitle +import org.wikipedia.readinglist.database.ReadingListPage +import org.wikipedia.readinglist.db.ReadingListPageDao +import org.wikipedia.util.StringUtil + +class InterestSelectionRepository( + private val interestTopicDao: InterestTopicDao, + private val interestArticleDao: InterestArticleDao, + private val historyEntryWithImageDao: HistoryEntryWithImageDao, + private val readingListPageDao: ReadingListPageDao, + val wikiSite: WikiSite +) { + + suspend fun getTopics(langCode: String): List { + val allMsgKey = OnboardingTopics.all.joinToString("|") { it.msgKey } + val response = ServiceFactory.get(wikiSite).getMessages(messages = allMsgKey, args = null, lang = langCode) + val translations = response.query?.allmessages + ?.filterNot { it.missing } + ?.associate { it.name to it.content } + .orEmpty() + return OnboardingTopics.all.map { topic -> + topic.copy(displayTitle = translations[topic.msgKey] ?: topic.displayTitle) + } + } + + suspend fun getArticlesByTopic(topic: String): List { + val searchTerm = "articletopic:$topic" + val response = ServiceFactory.get(wikiSite).getArticlesByTopic(searchTerm, 25) + val pageList = response.query?.pages + ?.map { page -> + PageTitle( + text = page.title, + wiki = wikiSite, + thumbUrl = page.thumbUrl(), + description = page.description, + displayText = page.displayTitle(wikiSite.languageCode) + ) + } ?: emptyList() + return pageList + } + + suspend fun loadInitialArticles(): List { + val maxItems = 20 + val results = mutableListOf() + + // get most recent history entries + val historyTitles = historyEntryWithImageDao.findEntryForReadMore(maxItems, 0) + .map { it.title } + // and a random sampling of reading list pages + val readingListTitles = readingListPageDao.getPagesByRandom(maxItems) + .map { ReadingListPage.toPageTitle(it) } + // take the two lists and interleave them + for (i in 0 until maxItems) { + if (i < historyTitles.size && !results.contains(historyTitles[i])) results.add(historyTitles[i]) + if (i < readingListTitles.size && !results.contains(readingListTitles[i])) results.add(readingListTitles[i]) + } + // remove non-main namespace articles, or Main page + results.removeAll { it.isMainPage || it.namespace() != Namespace.MAIN } + + // If there are still VERY few items, include a few random articles. + val maxRandomItems = 6 + if (results.size < maxRandomItems) { + for (i in results.size until maxRandomItems) { + val title = ServiceFactory.getRest(wikiSite).getRandomSummary() + .getPageTitle(wikiSite) + if (!results.contains(title)) { + results.add(title) + } + } + } + + // Hydrate titles, if necessary + val itemsNeedingCall = results + .filter { it.description.isNullOrEmpty() || it.thumbUrl.isNullOrEmpty() } + .groupBy { it.wikiSite } + itemsNeedingCall.keys.forEach { site -> + val pageList = ServiceFactory.get(site).getInfoByPageIdsOrTitles(titles = itemsNeedingCall[site]?.joinToString("|") { it.prefixedText }) + .query?.pages.orEmpty() + pageList.forEach { page -> + results.find { it.prefixedText == StringUtil.addUnderscores(page.title) }?.let { title -> + title.description = page.description + title.thumbUrl = page.thumbUrl() + } + } + } + + return results.distinctBy { it.prefixedText } + } + + suspend fun getPersistedTopics(lang: String): List { + return interestTopicDao.getAll(lang).mapNotNull { entity -> + OnboardingTopics.all.find { it.topicId == entity.topicId } + } + } + + suspend fun getPersistedArticles(lang: String): List { + return interestArticleDao.getAll(lang).map { entity -> + PageTitle( + text = entity.apiTitle, + wiki = wikiSite, + thumbUrl = entity.thumbUrl, + description = entity.description, + displayText = entity.displayTitle + ) + } + } + + suspend fun saveTopic(topic: OnboardingTopic, lang: String) { + interestTopicDao.insert( + interestTopic = InterestTopic( + topicId = topic.topicId, + topicLabel = topic.displayTitle, + queryTopicId = topic.queryTopicId, + lang = lang + ) + ) + } + + suspend fun deleteTopic(topic: OnboardingTopic, lang: String) { + interestTopicDao.delete( + interestTopic = InterestTopic( + topicId = topic.topicId, + topicLabel = topic.displayTitle, + queryTopicId = topic.queryTopicId, + lang = lang + ) + ) + } + + suspend fun saveArticle(article: PageTitle, lang: String, topic: OnboardingTopic?) { + interestArticleDao.insert( + interestArticle = InterestArticle( + apiTitle = article.prefixedText, + lang = lang, + namespace = article.namespace(), + displayTitle = article.displayText, + description = article.description.orEmpty(), + thumbUrl = article.thumbUrl.orEmpty(), + topicId = topic?.topicId, + topicLang = lang + ) + ) + } + + suspend fun deleteArticle(article: PageTitle, lang: String, topic: OnboardingTopic?) { + interestArticleDao.delete( + interestArticle = InterestArticle( + apiTitle = article.prefixedText, + lang = lang, + namespace = article.namespace(), + displayTitle = article.displayText, + description = article.description.orEmpty(), + thumbUrl = article.thumbUrl.orEmpty(), + topicId = topic?.topicId, + topicLang = lang + ) + ) + } + + @Transaction + suspend fun deleteAllInterests() { + interestTopicDao.deleteAll() + interestArticleDao.deleteAll() + } +} diff --git a/app/src/main/java/org/wikipedia/feed/personalization/interest/InterestSelectionScreen.kt b/app/src/main/java/org/wikipedia/feed/personalization/interest/InterestSelectionScreen.kt new file mode 100644 index 00000000000..e7a696ff956 --- /dev/null +++ b/app/src/main/java/org/wikipedia/feed/personalization/interest/InterestSelectionScreen.kt @@ -0,0 +1,371 @@ +package org.wikipedia.feed.personalization.interest + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState +import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid +import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells +import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan +import androidx.compose.foundation.lazy.staggeredgrid.items +import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.layout.layout +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import org.wikipedia.R +import org.wikipedia.compose.components.ArticleCard +import org.wikipedia.compose.components.SearchBarCard +import org.wikipedia.compose.components.error.WikiErrorClickEvents +import org.wikipedia.compose.components.error.WikiErrorView +import org.wikipedia.compose.extensions.shimmerEffect +import org.wikipedia.compose.theme.BaseTheme +import org.wikipedia.compose.theme.WikipediaTheme +import org.wikipedia.dataclient.WikiSite +import org.wikipedia.feed.personalization.topics.OnboardingTopics +import org.wikipedia.page.PageTitle +import org.wikipedia.theme.Theme + +@Composable +fun InterestOnboardingScreen( + modifier: Modifier = Modifier, + topicsState: TopicsState, + articlesState: ArticlesState, + onTopicSelected: (OnboardingTopic) -> Unit, + onItemClick: (PageTitle) -> Unit = {}, + onSearchClick: () -> Unit, + onDeselectAllClick: () -> Unit, + retryLoading: () -> Unit, + showError: (Throwable) -> Unit, + totalSelectedCount: Int, + gridState: LazyStaggeredGridState = rememberLazyStaggeredGridState() +) { + val transition = rememberInfiniteTransition(label = "shimmerTransition") + Box(modifier = modifier) { + Column { + Text( + modifier = Modifier.padding(horizontal = 16.dp), + text = stringResource(id = R.string.recommended_reading_list_interest_pick_title), + style = MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.Medium + ), + color = WikipediaTheme.colors.primaryColor + ) + Spacer(modifier = Modifier.height(16.dp)) + + LazyVerticalStaggeredGrid( + columns = StaggeredGridCells.Adaptive(140.dp), + modifier = Modifier + .fillMaxSize(), + state = gridState, + verticalItemSpacing = 16.dp, + horizontalArrangement = Arrangement.spacedBy(16.dp), + contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 16.dp), + content = { + item(span = StaggeredGridItemSpan.FullLine) { + SearchBarCard( + onSearchClick = onSearchClick, + text = stringResource(R.string.recommended_reading_list_interest_pick_search_hint) + ) + } + item(span = StaggeredGridItemSpan.FullLine) { + Box( + modifier = Modifier.layout { measurable, constraints -> + val extra = 16.dp.roundToPx() * 2 + val placeable = measurable.measure( + constraints.copy( + minWidth = constraints.minWidth + extra, + maxWidth = constraints.maxWidth + extra + ) + ) + layout(placeable.width, placeable.height) { + placeable.place(0, 0) + } + } + ) { + when (topicsState) { + is TopicsState.Error -> { + showError(topicsState.message) + } + TopicsState.Loading -> { + LazyRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(horizontal = 16.dp) + ) { + items(5) { + Box( + modifier = Modifier + .width(80.dp) + .height(32.dp) + .clip(RoundedCornerShape(size = 8.dp)) + .shimmerEffect(transition = transition) + ) + } + } + } + + is TopicsState.Success -> { + TopicFilterChipRow( + topics = topicsState.topics, + onTopicSelected = { onTopicSelected(it) } + ) + } + } + } + } + + when (articlesState) { + is ArticlesState.Error -> { + item(span = StaggeredGridItemSpan.FullLine) { + Box( + modifier = modifier + .fillMaxSize(), + contentAlignment = Alignment.Center + ) { + WikiErrorView( + modifier = Modifier + .fillMaxWidth(), + caught = articlesState.message, + errorClickEvents = WikiErrorClickEvents( + retryClickListener = retryLoading + ), + retryForGenericError = true + ) + } + } + } + ArticlesState.Loading -> { + items(10) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(200.dp) + .clip(RoundedCornerShape(size = 16.dp)) + .shimmerEffect(transition = transition) + ) + } + } + + is ArticlesState.Success -> { + items(articlesState.articles) { item -> + ArticleCard( + modifier = Modifier.animateItem(), + item = item, + isSelected = articlesState.selectedArticles.contains(item), + onItemClick = { onItemClick(item) } + ) + } + item(span = StaggeredGridItemSpan.FullLine) { + Spacer( + modifier = Modifier.height(64.dp) + ) + } + } + } + } + ) + } + + SelectionBottomBar( + modifier = Modifier + .align(Alignment.BottomStart) + .background(WikipediaTheme.colors.paperColor) + .clickable(enabled = false, onClick = {}), + selectedItemsCount = totalSelectedCount, + onDeselectAllClick = onDeselectAllClick + ) + } +} + +@Composable +fun TopicFilterChipRow( + topics: List, + modifier: Modifier = Modifier, + onTopicSelected: (OnboardingTopic) -> Unit +) { + LazyRow( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(horizontal = 16.dp) + ) { + items(items = topics, key = { it.topicId }) { item -> + FilterChip( + label = { Text(item.displayTitle) }, + selected = item.isSelected, + onClick = { onTopicSelected(item) }, + leadingIcon = { + AnimatedContent( + targetState = item.isSelected, + label = "topicSelectionIcon" + ) { isSelected -> + Icon( + modifier = Modifier + .size(16.dp), + painter = if (isSelected) { + R.drawable.ic_check_black_24dp + } else { + R.drawable.ic_add_gray_white_24dp + }.let { painterResource(id = it) }, + contentDescription = null + ) + } + }, + colors = FilterChipDefaults.filterChipColors( + containerColor = WikipediaTheme.colors.backgroundColor, + labelColor = WikipediaTheme.colors.primaryColor, + iconColor = WikipediaTheme.colors.primaryColor, + selectedLeadingIconColor = WikipediaTheme.colors.progressiveColor, + selectedContainerColor = WikipediaTheme.colors.additionColor, + selectedLabelColor = WikipediaTheme.colors.progressiveColor + ), + border = FilterChipDefaults.filterChipBorder( + enabled = true, + selected = item.isSelected, + borderColor = WikipediaTheme.colors.borderColor, + selectedBorderColor = Color.Transparent + ) + ) + } + } +} + +@Composable +fun SelectionBottomBar( + selectedItemsCount: Int, + modifier: Modifier = Modifier, + onDeselectAllClick: () -> Unit +) { + Column( + modifier = modifier + ) { + HorizontalDivider( + thickness = 0.5.dp, + color = WikipediaTheme.colors.borderColor + ) + AnimatedContent( + targetState = selectedItemsCount > 0 + ) { isSelected -> + if (isSelected) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = stringResource( + R.string.multi_select_items_selected, + selectedItemsCount + ), + style = MaterialTheme.typography.labelLarge.copy( + fontWeight = FontWeight.Bold, + color = WikipediaTheme.colors.primaryColor + ) + ) + + Button( + onClick = onDeselectAllClick, + colors = ButtonDefaults.filledTonalButtonColors( + containerColor = WikipediaTheme.colors.backgroundColor, + contentColor = WikipediaTheme.colors.secondaryColor + ) + ) { + Text( + modifier = Modifier + .clip(RoundedCornerShape(size = 8.dp)), + text = stringResource(R.string.explore_feed_deselect_all_button_label), + style = MaterialTheme.typography.labelLarge + ) + } + } + } else { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = stringResource(R.string.recommended_reading_list_interest_select_minimum), + style = MaterialTheme.typography.labelLarge, + color = WikipediaTheme.colors.primaryColor + ) + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun InterestOnboardingScreenPreview() { + val site = WikiSite("https://en.wikipedia.org/".toUri(), "en") + val titles = listOf( + PageTitle(text = "Psychology of art", wiki = site, thumbUrl = "foo.jpg", description = "Study of mental functions and behaviors", displayText = null), + PageTitle(text = "Industrial design", wiki = site, thumbUrl = "foo.jpg", description = "Process of design applied to physical products", displayText = null), + PageTitle(text = "Dufourspitze", wiki = site, thumbUrl = "foo.jpg", description = "Highest mountain in Switzerland", displayText = null), + PageTitle(text = "Sample title without description", wiki = site, thumbUrl = "foo.jpg", description = "", displayText = null), + PageTitle(text = "Sample title without thumbnail", wiki = site, thumbUrl = "", description = "Sample description", displayText = null), + PageTitle(text = "Octagon house", wiki = site, thumbUrl = "foo.jpg", description = "North American house style briefly popular in the 1850s", displayText = null), + PageTitle(text = "Barack Obama", wiki = site, thumbUrl = "foo.jpg", description = "President of the United States from 2009 to 2017", displayText = null), + ) + BaseTheme( + currentTheme = Theme.LIGHT + ) { + InterestOnboardingScreen( + modifier = Modifier + .fillMaxSize() + .background(WikipediaTheme.colors.paperColor) + .padding(top = 40.dp), + totalSelectedCount = 0, + topicsState = TopicsState.Success( + topics = OnboardingTopics.all.map { + it.copy(displayTitle = it.msgKey, isSelected = it.topicId == "science") + } + ), + articlesState = ArticlesState.Success( + articles = titles, + selectedArticles = setOf() + ), + onTopicSelected = {}, + onSearchClick = {}, + onDeselectAllClick = {}, + retryLoading = {}, + showError = {} + ) + } +} diff --git a/app/src/main/java/org/wikipedia/feed/personalization/interest/InterestSelectionState.kt b/app/src/main/java/org/wikipedia/feed/personalization/interest/InterestSelectionState.kt new file mode 100644 index 00000000000..eca1e9d36b9 --- /dev/null +++ b/app/src/main/java/org/wikipedia/feed/personalization/interest/InterestSelectionState.kt @@ -0,0 +1,29 @@ +package org.wikipedia.feed.personalization.interest + +import org.wikipedia.page.PageTitle + +data class OnboardingTopic( + val topicId: String, + val msgKey: String, + val queryTopicId: String, + val displayTitle: String, + val isSelected: Boolean = false +) + +data class InterestUiState( + val topicsState: TopicsState = TopicsState.Loading, + val articlesState: ArticlesState = ArticlesState.Loading, + val totalSelectedCount: Int = 0 +) + +sealed interface TopicsState { + data object Loading : TopicsState + data class Success(val topics: List) : TopicsState + data class Error(val message: Throwable) : TopicsState +} + +sealed interface ArticlesState { + data object Loading : ArticlesState + data class Success(val articles: List, val selectedArticles: Set) : ArticlesState + data class Error(val message: Throwable) : ArticlesState +} diff --git a/app/src/main/java/org/wikipedia/feed/personalization/topics/OnboardingTopics.kt b/app/src/main/java/org/wikipedia/feed/personalization/topics/OnboardingTopics.kt new file mode 100644 index 00000000000..b8cd17e7a8d --- /dev/null +++ b/app/src/main/java/org/wikipedia/feed/personalization/topics/OnboardingTopics.kt @@ -0,0 +1,243 @@ +package org.wikipedia.feed.personalization.topics + +import org.wikipedia.feed.personalization.interest.OnboardingTopic + +// the values defined here are from https://gerrit.wikimedia.org/r/plugins/gitiles/mediawiki/extensions/WikimediaMessages/+/refs/heads/master/includes/ArticleTopicFiltersRegistry.php +object OnboardingTopics { + val all = listOf( + OnboardingTopic( + topicId = "architecture", + msgKey = "wikimedia-articletopics-topic-architecture", + queryTopicId = "architecture", + displayTitle = "Architecture" + ), + OnboardingTopic( + topicId = "art", // registry uses "art" as topicId but articleTopics = "visual-arts" + msgKey = "wikimedia-articletopics-topic-art", + queryTopicId = "visual-arts", + displayTitle = "Art" + ), + OnboardingTopic( + topicId = "comics-and-anime", + msgKey = "wikimedia-articletopics-topic-comics-and-anime", + queryTopicId = "comics-and-anime", + displayTitle = "Comics and Anime" + ), + OnboardingTopic( + topicId = "entertainment", + msgKey = "wikimedia-articletopics-topic-entertainment", + queryTopicId = "entertainment", + displayTitle = "Entertainment" + ), + OnboardingTopic( + topicId = "fashion", + msgKey = "wikimedia-articletopics-topic-fashion", + queryTopicId = "fashion", + displayTitle = "Fashion" + ), + OnboardingTopic( + topicId = "literature", + msgKey = "wikimedia-articletopics-topic-literature", + queryTopicId = "books", // registry uses "literature" as topicId but articleTopics = "books" + displayTitle = "Literature" + ), + OnboardingTopic( + topicId = "music", + msgKey = "wikimedia-articletopics-topic-music", + queryTopicId = "music", + displayTitle = "Music" + ), + OnboardingTopic( + topicId = "performing-arts", + msgKey = "wikimedia-articletopics-topic-performing-arts", + queryTopicId = "performing-arts", + displayTitle = "Performing arts" + ), + OnboardingTopic( + topicId = "sports", + msgKey = "wikimedia-articletopics-topic-sports", + queryTopicId = "sports", + displayTitle = "Sports" + ), + OnboardingTopic( + topicId = "tv-and-film", + msgKey = "wikimedia-articletopics-topic-tv-and-film", + queryTopicId = "films", + displayTitle = "TV and Film" + ), + OnboardingTopic( + topicId = "video-games", + msgKey = "wikimedia-articletopics-topic-video-games", + queryTopicId = "video-games", + displayTitle = "Video Games" + ), + OnboardingTopic( + topicId = "biography", + msgKey = "wikimedia-articletopics-topic-biography", + queryTopicId = "biography", + displayTitle = "Biography" + ), + OnboardingTopic( + topicId = "women", + msgKey = "wikimedia-articletopics-topic-women", + queryTopicId = "women", + displayTitle = "Women" + ), + OnboardingTopic( + topicId = "business-and-economics", + msgKey = "wikimedia-articletopics-topic-business-and-economics", + queryTopicId = "business-and-economics", + displayTitle = "Business and economics" + ), + OnboardingTopic( + topicId = "education", + msgKey = "wikimedia-articletopics-topic-education", + queryTopicId = "education", + displayTitle = "Education" + ), + OnboardingTopic( + topicId = "food-and-drink", + msgKey = "wikimedia-articletopics-topic-food-and-drink", + queryTopicId = "food-and-drink", + displayTitle = "Food and Drink" + ), + OnboardingTopic( + topicId = "history", + msgKey = "wikimedia-articletopics-topic-history", + queryTopicId = "history", + displayTitle = "History" + ), + OnboardingTopic( + topicId = "military-and-warfare", + msgKey = "wikimedia-articletopics-topic-military-and-warfare", + queryTopicId = "military-and-warfare", + displayTitle = "Military and warfare" + ), + OnboardingTopic( + topicId = "philosophy-and-religion", + msgKey = "wikimedia-articletopics-topic-philosophy-and-religion", + queryTopicId = "philosophy-and-religion", + displayTitle = "Philosophy and religion" + ), + OnboardingTopic( + topicId = "politics-and-government", + msgKey = "wikimedia-articletopics-topic-politics-and-government", + queryTopicId = "politics-and-government", + displayTitle = "Politics and government" + ), + OnboardingTopic( + topicId = "society", + msgKey = "wikimedia-articletopics-topic-society", + queryTopicId = "society", + displayTitle = "Society" + ), + OnboardingTopic( + topicId = "transportation", + msgKey = "wikimedia-articletopics-topic-transportation", + queryTopicId = "transportation", + displayTitle = "Transportation" + ), + OnboardingTopic( + topicId = "biology", + msgKey = "wikimedia-articletopics-topic-biology", + queryTopicId = "biology", + displayTitle = "Biology" + ), + OnboardingTopic( + topicId = "chemistry", + msgKey = "wikimedia-articletopics-topic-chemistry", + queryTopicId = "chemistry", + displayTitle = "Chemistry" + ), + OnboardingTopic( + topicId = "computers-and-internet", // registry uses "computers-and-internet" as topicId but articleTopics = "internet-culture" + msgKey = "wikimedia-articletopics-topic-computers-and-internet", + queryTopicId = "internet-culture", + displayTitle = "Internet Culture" + ), + OnboardingTopic( + topicId = "earth-and-environment", // registry uses "earth-and-environment" as topicId but articleTopics = "geographical" + msgKey = "wikimedia-articletopics-topic-earth-and-environment", + queryTopicId = "geographical", + displayTitle = "Geographical" + ), + OnboardingTopic( + topicId = "engineering", + msgKey = "wikimedia-articletopics-topic-engineering", + queryTopicId = "engineering", + displayTitle = "Engineering" + ), + OnboardingTopic( + topicId = "general-science", // registry uses "general-science" as topicId but articleTopics = "stem" + msgKey = "wikimedia-articletopics-topic-general-science", + queryTopicId = "stem", + displayTitle = "STEM" + ), + OnboardingTopic( + topicId = "mathematics", + msgKey = "wikimedia-articletopics-topic-mathematics", + queryTopicId = "mathematics", + displayTitle = "Mathematics" + ), + OnboardingTopic( + topicId = "medicine-and-health", + msgKey = "wikimedia-articletopics-topic-medicine-and-health", + queryTopicId = "medicine-and-health", + displayTitle = "Medicine and Health" + ), + OnboardingTopic( + topicId = "physics", + msgKey = "wikimedia-articletopics-topic-physics", + queryTopicId = "physics", + displayTitle = "Physics" + ), + OnboardingTopic( + topicId = "technology", + msgKey = "wikimedia-articletopics-topic-technology", + queryTopicId = "technology", + displayTitle = "Technology" + ), + OnboardingTopic( + topicId = "africa", + msgKey = "wikimedia-articletopics-topic-africa", + queryTopicId = "africa", + displayTitle = "Africa" + ), + OnboardingTopic( + topicId = "asia", + msgKey = "wikimedia-articletopics-topic-asia", + queryTopicId = "asia", + displayTitle = "Asia" + ), + OnboardingTopic( + topicId = "central-america", + msgKey = "wikimedia-articletopics-topic-central-america", + queryTopicId = "central-america", + displayTitle = "Central America" + ), + OnboardingTopic( + topicId = "europe", + msgKey = "wikimedia-articletopics-topic-europe", + queryTopicId = "europe", + displayTitle = "Europe" + ), + OnboardingTopic( + topicId = "north-america", + msgKey = "wikimedia-articletopics-topic-north-america", + queryTopicId = "north-america", + displayTitle = "North America" + ), + OnboardingTopic( + topicId = "oceania", + msgKey = "wikimedia-articletopics-topic-oceania", + queryTopicId = "oceania", + displayTitle = "Oceania" + ), + OnboardingTopic( + topicId = "south-america", + msgKey = "wikimedia-articletopics-topic-south-america", + queryTopicId = "south-america", + displayTitle = "South America" + ) + ) +} diff --git a/app/src/main/java/org/wikipedia/feed/topread/TopReadModule.kt b/app/src/main/java/org/wikipedia/feed/topread/TopReadModule.kt new file mode 100644 index 00000000000..deb273fabf6 --- /dev/null +++ b/app/src/main/java/org/wikipedia/feed/topread/TopReadModule.kt @@ -0,0 +1,249 @@ +package org.wikipedia.feed.topread + +import android.content.Context +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil3.compose.AsyncImage +import org.wikipedia.R +import org.wikipedia.compose.components.HtmlText +import org.wikipedia.compose.theme.BaseTheme +import org.wikipedia.compose.theme.WikipediaTheme +import org.wikipedia.dataclient.WikiSite +import org.wikipedia.dataclient.page.PageSummary +import org.wikipedia.extensions.getString +import org.wikipedia.feed.CommunityModuleHeader +import org.wikipedia.theme.Theme +import org.wikipedia.util.StringUtil + +@Composable +fun TopReadModule( + wikiSite: WikiSite, + topRead: TopRead, + onOverflowClick: () -> Unit, + onPageClick: (PageSummary) -> Unit, + onPageOverflowClick: (PageSummary) -> Unit, + onFooterClick: () -> Unit +) { + val maxTopReadItems = 5 + val context = LocalContext.current + Column( + modifier = Modifier + .fillMaxWidth() + .background(color = WikipediaTheme.colors.backgroundColor) + ) { + CommunityModuleHeader( + wikiSite = wikiSite, + titleResId = R.string.view_top_read_card_title, + subTitleResId = R.string.view_top_read_card_description, + onOverflowClick = onOverflowClick + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) { + topRead.articles.take(maxTopReadItems).forEachIndexed { index, article -> + val isTrendingUp = article.viewHistory?.takeLast(2)?.let { it.size < 2 || it[1].views > it[0].views } ?: true + + TopReadItem( + context = context, + wikiSite = wikiSite, + rank = index + 1, + isTrendingUp = isTrendingUp, + pageSummary = article, + onClick = onPageClick, + onMoreClick = onPageOverflowClick + ) + } + } + + TextButton( + modifier = Modifier + .align(Alignment.End) + .padding(bottom = 16.dp), + onClick = onFooterClick + ) { + Text( + text = context.getString(wikiSite.languageCode, R.string.view_top_read_card_action), + style = MaterialTheme.typography.labelLarge, + color = WikipediaTheme.colors.progressiveColor, + fontWeight = FontWeight.Medium + ) + Spacer(modifier = Modifier.width(8.dp)) + Icon( + painter = painterResource(R.drawable.ic_arrow_forward_black_24dp), + contentDescription = context.getString(wikiSite.languageCode, R.string.view_top_read_card_action), + tint = WikipediaTheme.colors.progressiveColor + ) + } + } +} + +@Composable +fun TopReadItem( + context: Context, + wikiSite: WikiSite, + rank: Int, + isTrendingUp: Boolean, + pageSummary: PageSummary, + onClick: (PageSummary) -> Unit, + onMoreClick: (PageSummary) -> Unit +) { + Column( + modifier = Modifier.fillMaxWidth() + .clickable(onClick = { + onClick(pageSummary) + }) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(WikipediaTheme.colors.borderColor) + ) + Spacer(modifier = Modifier.height(4.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp) + ) { + Column(modifier = Modifier.weight(1f)) { + Row { + Box( + modifier = Modifier + .size(24.dp) + .background( + color = WikipediaTheme.colors.progressiveColor, + shape = RoundedCornerShape(4.dp) + ), + contentAlignment = Alignment.Center + ) { + Text( + text = rank.toString(), + fontSize = 16.sp, + color = WikipediaTheme.colors.paperColor, + fontFamily = FontFamily.Serif, + fontWeight = FontWeight.Bold + ) + } + + Spacer(modifier = Modifier.width(16.dp)) + + HtmlText( + text = pageSummary.displayTitle, + color = WikipediaTheme.colors.primaryColor, + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.SemiBold + ) + ) + } + Spacer(modifier = Modifier.height(8.dp)) + Row( + verticalAlignment = Alignment.CenterVertically + ) { + val trendingIcon = if (isTrendingUp) R.drawable.ic_trending_up_24dp else R.drawable.ic_trending_down_24dp + val trendingIconTint = if (isTrendingUp) WikipediaTheme.colors.successColor else WikipediaTheme.colors.destructiveColor + Icon( + modifier = Modifier.size(24.dp), + painter = painterResource(trendingIcon), + contentDescription = null, + tint = trendingIconTint + ) + Spacer(modifier = Modifier.width(16.dp)) + Text( + text = StringUtil.getPageViewText(context, pageSummary.views), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + color = WikipediaTheme.colors.secondaryColor + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = context.getString(wikiSite.languageCode, R.string.view_top_read_card_pageviews_views_suffix), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = WikipediaTheme.colors.secondaryColor + ) + } + } + + pageSummary.thumbnailUrl?.let { + AsyncImage( + model = pageSummary.thumbnailUrl, + contentDescription = null, + modifier = Modifier + .size(56.dp) + .clip(RoundedCornerShape(8.dp)), + contentScale = ContentScale.Crop + ) + } + + IconButton( + onClick = { + onMoreClick(pageSummary) + }, + content = { + Icon( + painter = painterResource(R.drawable.ic_more_vert_white_24dp), + contentDescription = context.getString(wikiSite.languageCode, R.string.menu_feed_overflow_label), + tint = WikipediaTheme.colors.placeholderColor + ) + } + ) + } + } +} + +@Preview(showBackground = true) +@Composable +fun TopReadCardPreview() { + val article = PageSummary( + displayTitle = "Test Article", + prefixTitle = "Test_Article", + description = "This is a test article.", + extract = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", + thumbnail = null, + lang = "en" + ) + BaseTheme(currentTheme = Theme.LIGHT) { + TopReadModule( + wikiSite = WikiSite.preview(), + topRead = TopRead( + articles = listOf(article, article, article, article, article) + ), + onFooterClick = {}, + onOverflowClick = {}, + onPageClick = {}, + onPageOverflowClick = {} + ) + } +} diff --git a/app/src/main/java/org/wikipedia/gallery/GalleryItem.kt b/app/src/main/java/org/wikipedia/gallery/GalleryItem.kt index 698aaf8d998..a350300606e 100644 --- a/app/src/main/java/org/wikipedia/gallery/GalleryItem.kt +++ b/app/src/main/java/org/wikipedia/gallery/GalleryItem.kt @@ -31,6 +31,7 @@ open class GalleryItem { val caption: TextInfo? = null val sources: List? = null + val credit: TextInfo? = null var titles: Titles? = null var artist: ArtistInfo? = null var license: ImageLicense? = null diff --git a/app/src/main/java/org/wikipedia/games/onthisday/OnThisDayGameActivity.kt b/app/src/main/java/org/wikipedia/games/onthisday/OnThisDayGameActivity.kt index 6a68413ec68..3c6e0d28ef6 100644 --- a/app/src/main/java/org/wikipedia/games/onthisday/OnThisDayGameActivity.kt +++ b/app/src/main/java/org/wikipedia/games/onthisday/OnThisDayGameActivity.kt @@ -183,7 +183,7 @@ class OnThisDayGameActivity : BaseActivity(), BaseActivity.Callback { startActivity(MainActivity.newIntent(this) .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) .putExtra(Constants.INTENT_RETURN_TO_MAIN, true) - .putExtra(Constants.INTENT_EXTRA_GO_TO_MAIN_TAB, NavTab.EXPLORE.code())) + .putExtra(Constants.INTENT_EXTRA_GO_TO_MAIN_TAB, NavTab.HOME.code())) finish() } diff --git a/app/src/main/java/org/wikipedia/history/HistoryEntry.kt b/app/src/main/java/org/wikipedia/history/HistoryEntry.kt index 6be73b7668d..652c57ceff3 100644 --- a/app/src/main/java/org/wikipedia/history/HistoryEntry.kt +++ b/app/src/main/java/org/wikipedia/history/HistoryEntry.kt @@ -103,5 +103,6 @@ class HistoryEntry( const val SOURCE_ON_THIS_DAY_GAME = 43 const val SOURCE_RECOMMENDED_READING_LIST = 44 const val SOURCE_ACTIVITY_TAB = 45 + const val SOURCE_FEED_ON_THIS_DAY = 46 } } diff --git a/app/src/main/java/org/wikipedia/history/db/HistoryEntryWithImageDao.kt b/app/src/main/java/org/wikipedia/history/db/HistoryEntryWithImageDao.kt index 38e5a9ea290..854b17f45b7 100644 --- a/app/src/main/java/org/wikipedia/history/db/HistoryEntryWithImageDao.kt +++ b/app/src/main/java/org/wikipedia/history/db/HistoryEntryWithImageDao.kt @@ -64,6 +64,10 @@ interface HistoryEntryWithImageDao { else SearchResults(entries.take(3).map { SearchResult(toHistoryEntry(it).title, SearchResult.SearchResultType.HISTORY) }.toMutableList()) } + suspend fun getMostRecentEntriesWithImage(limit: Int): List { + return getHistoryEntriesWithOffset(limit, 0).map { toHistoryEntry(it) } + } + suspend fun filterHistoryItemsWithoutTime(searchQuery: String = ""): List { return findEntriesBySearchTerm("%${normalizedQuery(searchQuery)}%").map { toHistoryEntry(it) } } diff --git a/app/src/main/java/org/wikipedia/main/MainActivity.kt b/app/src/main/java/org/wikipedia/main/MainActivity.kt index d1256ad159e..e7c5bc952dc 100644 --- a/app/src/main/java/org/wikipedia/main/MainActivity.kt +++ b/app/src/main/java/org/wikipedia/main/MainActivity.kt @@ -2,30 +2,38 @@ package org.wikipedia.main import android.content.Context import android.content.Intent +import android.graphics.Color import android.os.Bundle import android.view.View import androidx.activity.addCallback import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.content.res.AppCompatResources import androidx.appcompat.view.ActionMode +import androidx.appcompat.view.ContextThemeWrapper import androidx.appcompat.widget.Toolbar import androidx.core.graphics.Insets import androidx.core.net.toUri import androidx.core.view.WindowInsetsCompat +import androidx.core.view.isVisible import androidx.core.view.updatePadding import androidx.fragment.app.Fragment import org.wikipedia.Constants import org.wikipedia.R +import org.wikipedia.WikipediaApp import org.wikipedia.activity.SingleFragmentActivity import org.wikipedia.analytics.eventplatform.ImageRecommendationsEvent import org.wikipedia.analytics.eventplatform.PatrollerExperienceEvent import org.wikipedia.databinding.ActivityMainBinding import org.wikipedia.dataclient.WikiSite import org.wikipedia.feed.FeedFragment +import org.wikipedia.feed.HomeFragment +import org.wikipedia.feed.HomeTab import org.wikipedia.navtab.NavTab import org.wikipedia.onboarding.InitialOnboardingActivity import org.wikipedia.page.PageActivity import org.wikipedia.settings.Prefs +import org.wikipedia.theme.Theme import org.wikipedia.util.DeviceUtil import org.wikipedia.util.DimenUtil import org.wikipedia.util.FeedbackUtil @@ -80,7 +88,7 @@ class MainActivity : SingleFragmentActivity(), MainFragment.Callba !intent.hasExtra(Constants.INTENT_EXTRA_PREVIEW_SAVED_READING_LISTS)) { onboardingLauncher.launch(InitialOnboardingActivity.newIntent(this)) } - setNavigationBarColor(ResourceUtil.getThemedColor(this, R.attr.paper_color)) + setNavigationBarColor(Color.TRANSPARENT) setSupportActionBar(binding.mainToolbar) supportActionBar?.title = "" supportActionBar?.setDisplayHomeAsUpEnabled(false) @@ -101,11 +109,13 @@ class MainActivity : SingleFragmentActivity(), MainFragment.Callba } override fun onTabChanged(tab: NavTab) { - if (tab == NavTab.EXPLORE) { - // TODO: conditionally hide toolbar if we're looking at a full-bleed Compose feed. + if (tab == NavTab.HOME) { + binding.mainToolbar.isVisible = false binding.mainToolbarWordmark.visibility = View.VISIBLE binding.mainToolbar.title = "" controlNavTabInFragment = false + + applyNavBarTheme(if ((fragment.currentFragment as? HomeFragment)?.getCurrentTab() == HomeTab.FOR_YOU) Theme.BLACK else WikipediaApp.instance.currentTheme) } else { if (tab == NavTab.SEARCH && Prefs.showSearchTabTooltip) { FeedbackUtil.showTooltip(this, fragment.binding.mainNavTabLayout.findViewById(NavTab.SEARCH.id), getString(R.string.search_tab_tooltip), aboveOrBelow = true, autoDismiss = false) @@ -117,20 +127,36 @@ class MainActivity : SingleFragmentActivity(), MainFragment.Callba } binding.mainToolbarWordmark.visibility = View.GONE binding.mainToolbar.setTitle(tab.text) + binding.mainToolbar.isVisible = true controlNavTabInFragment = true + + applyNavBarTheme(WikipediaApp.instance.currentTheme) } applyInsets() fragment.requestUpdateToolbarElevation() } private fun applyInsets() { - // TODO: conditionally set padding if we're looking at a full-bleed Compose feed. binding.root.updatePadding( - top = statusBarInsets.top + navBarInsets.top, - bottom = statusBarInsets.bottom + navBarInsets.bottom, + top = if (fragment.currentFragment is HomeFragment) 0 else statusBarInsets.top + navBarInsets.top, left = statusBarInsets.left + navBarInsets.left, right = statusBarInsets.right + navBarInsets.right ) + fragment.binding.mainNavTabContainer.updatePadding( + bottom = statusBarInsets.bottom + navBarInsets.bottom + ) + } + + private fun applyNavBarTheme(theme: Theme) { + val wrapper = ContextThemeWrapper(this, theme.resourceId) + val paperColor = ResourceUtil.getThemedColor(wrapper, R.attr.paper_color) + val borderColor = ResourceUtil.getThemedColor(wrapper, R.attr.border_color) + val colorStateList = AppCompatResources.getColorStateList(wrapper, R.color.color_state_nav_tab) + fragment.binding.mainNavTabLayout.applyColors(paperColor, colorStateList) + fragment.binding.mainNavTabBorder.setBackgroundColor(borderColor) + fragment.binding.mainNavTabContainer.setBackgroundColor(paperColor) + setNavigationBarColor(paperColor) + DeviceUtil.setLightSystemUiVisibility(this@MainActivity, light = !theme.isDark) } override fun onSupportActionModeStarted(mode: ActionMode) { @@ -190,6 +216,10 @@ class MainActivity : SingleFragmentActivity(), MainFragment.Callba return binding.mainToolbar } + fun getStatusBarInsets(): Insets? { + return statusBarInsets + } + override fun onUnreadNotification() { fragment.updateNotificationDot(true) } diff --git a/app/src/main/java/org/wikipedia/main/MainFragment.kt b/app/src/main/java/org/wikipedia/main/MainFragment.kt index 2dda241539f..cbe96406f56 100644 --- a/app/src/main/java/org/wikipedia/main/MainFragment.kt +++ b/app/src/main/java/org/wikipedia/main/MainFragment.kt @@ -46,6 +46,7 @@ import org.wikipedia.auth.AccountUtil import org.wikipedia.commons.FilePageActivity import org.wikipedia.concurrency.FlowEventBus import org.wikipedia.databinding.FragmentMainBinding +import org.wikipedia.dataclient.Service import org.wikipedia.dataclient.WikiSite import org.wikipedia.events.ImportReadingListsEvent import org.wikipedia.events.LoggedOutEvent @@ -53,10 +54,8 @@ import org.wikipedia.events.LoggedOutInBackgroundEvent import org.wikipedia.events.NewRecommendedReadingListEvent import org.wikipedia.feed.FeedFragment import org.wikipedia.feed.image.FeaturedImage -import org.wikipedia.feed.image.FeaturedImageCard import org.wikipedia.feed.news.NewsActivity -import org.wikipedia.feed.news.NewsCard -import org.wikipedia.feed.news.NewsItemView +import org.wikipedia.feed.news.NewsItem import org.wikipedia.gallery.GalleryActivity import org.wikipedia.gallery.MediaDownloadReceiver import org.wikipedia.games.GamesHubActivity @@ -185,7 +184,7 @@ class MainFragment : Fragment(), BackPressedHandler, MenuProvider, FeedFragment. return@setOnItemSelectedListener false } val fragment = currentFragment - if (item.order == NavTab.EXPLORE.code() && fragment is FeedFragment) { + if (item.order == NavTab.HOME.code() && fragment is FeedFragment) { fragment.scrollToTop() } if (fragment is HistoryFragment && item.order == NavTab.SEARCH.code()) { @@ -354,9 +353,9 @@ class MainFragment : Fragment(), BackPressedHandler, MenuProvider, FeedFragment. } else if (intent.hasExtra(Constants.INTENT_EXTRA_DELETE_READING_LIST)) { onNavigateTo(NavTab.READING_LISTS) } else if (intent.hasExtra(Constants.INTENT_EXTRA_GO_TO_MAIN_TAB) && - !(binding.mainNavTabLayout.selectedItemId == NavTab.EXPLORE.code() && - intent.getIntExtra(Constants.INTENT_EXTRA_GO_TO_MAIN_TAB, NavTab.EXPLORE.code()) == NavTab.EXPLORE.code())) { - onNavigateTo(NavTab.of(intent.getIntExtra(Constants.INTENT_EXTRA_GO_TO_MAIN_TAB, NavTab.EXPLORE.code()))) + !(binding.mainNavTabLayout.selectedItemId == NavTab.HOME.code() && + intent.getIntExtra(Constants.INTENT_EXTRA_GO_TO_MAIN_TAB, NavTab.HOME.code()) == NavTab.HOME.code())) { + onNavigateTo(NavTab.of(intent.getIntExtra(Constants.INTENT_EXTRA_GO_TO_MAIN_TAB, NavTab.HOME.code()))) } else if (intent.hasExtra(Constants.INTENT_EXTRA_GO_TO_SE_TAB)) { onNavigateTo(NavTab.of(intent.getIntExtra(Constants.INTENT_EXTRA_GO_TO_SE_TAB, NavTab.EDITS.code()))) } else if (intent.hasExtra(Constants.INTENT_EXTRA_PREVIEW_SAVED_READING_LISTS)) { @@ -407,26 +406,23 @@ class MainFragment : Fragment(), BackPressedHandler, MenuProvider, FeedFragment. ReadingListBehaviorsUtil.moveToList(requireActivity(), sourceReadingListId, entry.title, InvokeSource.FEED) } - override fun onFeedNewsItemSelected(card: NewsCard, view: NewsItemView) { - val options = ActivityOptions.makeSceneTransitionAnimation(requireActivity(), view.imageView, getString(R.string.transition_news_item)) - view.newsItem?.let { - startActivity(NewsActivity.newIntent(requireActivity(), it, card.wikiSite()), if (it.thumb() != null) options.toBundle() else null) - } + override fun onFeedNewsItemSelected(newsItem: NewsItem, wikiSite: WikiSite) { + startActivity(NewsActivity.newIntent(requireActivity(), newsItem, wikiSite)) } override fun onFeedSeCardFooterClicked() { startActivity(SuggestedEditsTasksActivity.newIntent(requireActivity())) } - override fun onFeedShareImage(card: FeaturedImageCard) { - val thumbUrl = card.baseImage().thumbnailUrl - val fullSizeUrl = card.baseImage().original.source + override fun onFeedShareImage(image: FeaturedImage, age: Int) { + val thumbUrl = image.thumbnailUrl + val fullSizeUrl = image.original.source ImageService.loadImage(requireContext(), thumbUrl, onSuccess = { bitmap -> if (!isAdded) { return@loadImage } ShareUtil.shareImage(lifecycleScope, requireContext(), bitmap, File(thumbUrl).name, - ShareUtil.getFeaturedImageShareSubject(requireContext(), card.age()), fullSizeUrl) + ShareUtil.getFeaturedImageShareSubject(requireContext(), age), fullSizeUrl) }) } @@ -440,8 +436,8 @@ class MainFragment : Fragment(), BackPressedHandler, MenuProvider, FeedFragment. } } - override fun onFeaturedImageSelected(card: FeaturedImageCard) { - startActivity(FilePageActivity.newIntent(requireActivity(), PageTitle(card.filename(), card.wikiSite()))) + override fun onFeaturedImageSelected(image: FeaturedImage) { + startActivity(FilePageActivity.newIntent(requireActivity(), PageTitle(image.title, WikiSite(Service.COMMONS_URL)))) } override fun onLoginRequested() { diff --git a/app/src/main/java/org/wikipedia/navtab/NavTab.kt b/app/src/main/java/org/wikipedia/navtab/NavTab.kt index c45b1e0fce8..04e3f75d214 100644 --- a/app/src/main/java/org/wikipedia/navtab/NavTab.kt +++ b/app/src/main/java/org/wikipedia/navtab/NavTab.kt @@ -1,20 +1,18 @@ package org.wikipedia.navtab -import androidx.annotation.DrawableRes -import androidx.annotation.StringRes import androidx.fragment.app.Fragment import org.wikipedia.R import org.wikipedia.activitytab.ActivityTabFragment -import org.wikipedia.feed.FeedFragment +import org.wikipedia.feed.HomeFragment import org.wikipedia.history.HistoryFragment import org.wikipedia.model.EnumCode import org.wikipedia.readinglist.ReadingListsFragment -enum class NavTab(@StringRes val text: Int, val id: Int, @DrawableRes val icon: Int) : EnumCode { +enum class NavTab(val text: Int, val id: Int, val icon: Int) : EnumCode { - EXPLORE(R.string.feed, R.id.nav_tab_explore, R.drawable.selector_nav_explore) { + HOME(R.string.home, R.id.nav_tab_home, R.drawable.ic_home_filled_24dp) { override fun newInstance(): Fragment { - return FeedFragment.newInstance() + return HomeFragment() // FeedFragment.newInstance() } }, READING_LISTS(R.string.nav_item_saved, R.id.nav_tab_reading_lists, R.drawable.selector_nav_saved) { diff --git a/app/src/main/java/org/wikipedia/navtab/NavTabLayout.kt b/app/src/main/java/org/wikipedia/navtab/NavTabLayout.kt index 57f70449cee..33a2a26eb41 100644 --- a/app/src/main/java/org/wikipedia/navtab/NavTabLayout.kt +++ b/app/src/main/java/org/wikipedia/navtab/NavTabLayout.kt @@ -8,6 +8,7 @@ import android.view.Menu import android.view.View import android.view.ViewGroup import android.widget.ImageView +import androidx.annotation.ColorInt import androidx.core.view.isVisible import androidx.core.view.updateMarginsRelative import com.google.android.material.bottomnavigation.BottomNavigationView @@ -43,4 +44,10 @@ class NavTabLayout(context: Context, attrs: AttributeSet) : BottomNavigationView } overlayDotView?.isVisible = enabled } + + fun applyColors(@ColorInt backgroundColor: Int, navTabColorStateList: ColorStateList) { + setBackgroundColor(backgroundColor) + itemIconTintList = navTabColorStateList + itemTextColor = navTabColorStateList + } } diff --git a/app/src/main/java/org/wikipedia/onboarding/InitialOnboardingActivity.kt b/app/src/main/java/org/wikipedia/onboarding/InitialOnboardingActivity.kt index f76922ef882..bf426833bca 100644 --- a/app/src/main/java/org/wikipedia/onboarding/InitialOnboardingActivity.kt +++ b/app/src/main/java/org/wikipedia/onboarding/InitialOnboardingActivity.kt @@ -3,39 +3,115 @@ package org.wikipedia.onboarding import android.content.Context import android.content.Intent import android.os.Bundle -import android.os.PersistableBundle -import androidx.activity.addCallback -import org.wikipedia.activity.SingleFragmentActivity +import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.core.content.ContextCompat +import org.wikipedia.Constants +import org.wikipedia.R +import org.wikipedia.WikipediaApp +import org.wikipedia.activity.BaseActivity +import org.wikipedia.compose.theme.BaseTheme +import org.wikipedia.feed.personalization.PersonalizationActivity +import org.wikipedia.language.AppLanguageState import org.wikipedia.settings.Prefs +import org.wikipedia.settings.languages.WikipediaLanguagesActivity +import org.wikipedia.theme.Theme +import org.wikipedia.util.DeviceUtil +import org.wikipedia.util.ResourceUtil -class InitialOnboardingActivity : SingleFragmentActivity(), OnboardingFragment.Callback { - override fun onSkip() {} +class InitialOnboardingActivity : BaseActivity() { - override fun onComplete() { - setResult(if (fragment.languageChanged) RESULT_LANGUAGE_CHANGED else RESULT_OK) - Prefs.isInitialOnboardingEnabled = false - finish() + private val appLanguageCodesState = mutableStateOf(WikipediaApp.instance.languageState.appLanguageCodes.toList()) + + private val languagesLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + appLanguageCodesState.value = WikipediaApp.instance.languageState.appLanguageCodes.toList() + setResult(RESULT_LANGUAGE_CHANGED) } - override fun onCreate(savedInstanceState: Bundle?, persistentState: PersistableBundle?) { - super.onCreate(savedInstanceState, persistentState) - onBackPressedDispatcher.addCallback(this) { - if (fragment.onBackPressed()) { - return@addCallback + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + DeviceUtil.setEdgeToEdge(this) + setContent { + var currentTheme by remember { mutableStateOf(Theme.BLACK) } + var currentNavigationBarColor by remember { mutableIntStateOf(ContextCompat.getColor(window.context, android.R.color.black)) } + DeviceUtil.setLightSystemUiVisibility(this, !currentTheme.isDark) + setNavigationBarColor(currentNavigationBarColor) + BaseTheme( + currentTheme = currentTheme + ) { + AppOnboardingScreen( + languageState = WikipediaApp.instance.languageState, + appLanguageCodes = appLanguageCodesState.value, + isNewUser = Prefs.isInitialOnboardingEnabled, + onAddLanguageClick = { + languagesLauncher.launch(WikipediaLanguagesActivity.newIntent(this, Constants.InvokeSource.ONBOARDING_DIALOG)) + }, + onUpdateTheme = { + currentTheme = WikipediaApp.instance.currentTheme + currentNavigationBarColor = ResourceUtil.getThemedColor(this, R.attr.paper_color) + }, + onFinish = { + Prefs.isInitialOnboardingEnabled = false + Prefs.isExploreFeedUpdatePromptShown = true + startActivity(PersonalizationActivity.newIntent(this)) + finish() + } + ) } - setResult(RESULT_OK) - finish() } } - override fun createFragment(): InitialOnboardingFragment { - return InitialOnboardingFragment.newInstance() - } - companion object { + // TODO: need to refresh the language state at the final screen const val RESULT_LANGUAGE_CHANGED = 1 fun newIntent(context: Context): Intent { return Intent(context, InitialOnboardingActivity::class.java) } } } + +enum class OnboardingScreen { + INTRO, + DATA_PRIVACY, + LANGUAGES +} + +@Composable +fun AppOnboardingScreen( + modifier: Modifier = Modifier, + languageState: AppLanguageState?, + appLanguageCodes: List, + isNewUser: Boolean, + onAddLanguageClick: () -> Unit, + onUpdateTheme: () -> Unit, + onFinish: () -> Unit +) { + var showIntroScreen by remember { mutableStateOf(isNewUser) } + if (showIntroScreen) { + InitialOnboardingScreen( + modifier = modifier, + onboardingScreens = listOf(OnboardingScreen.INTRO, OnboardingScreen.DATA_PRIVACY, OnboardingScreen.LANGUAGES), + languageState = languageState, + appLanguageCodes = appLanguageCodes, + onAddLanguageClick = { + onAddLanguageClick() + }, + onNextClick = { + onUpdateTheme() + }, + onFinishClick = { + showIntroScreen = false + } + ) + } else { + // TODO: interest selection + onFinish() + } +} diff --git a/app/src/main/java/org/wikipedia/onboarding/InitialOnboardingFragment.kt b/app/src/main/java/org/wikipedia/onboarding/InitialOnboardingFragment.kt deleted file mode 100644 index 415f5495687..00000000000 --- a/app/src/main/java/org/wikipedia/onboarding/InitialOnboardingFragment.kt +++ /dev/null @@ -1,116 +0,0 @@ -package org.wikipedia.onboarding - -import android.net.Uri -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.activity.result.contract.ActivityResultContracts -import androidx.annotation.LayoutRes -import androidx.core.os.bundleOf -import androidx.fragment.app.Fragment -import androidx.viewpager2.adapter.FragmentStateAdapter -import org.wikipedia.Constants -import org.wikipedia.R -import org.wikipedia.activity.FragmentUtil -import org.wikipedia.login.LoginActivity -import org.wikipedia.model.EnumCode -import org.wikipedia.settings.languages.WikipediaLanguagesActivity -import org.wikipedia.util.FeedbackUtil -import org.wikipedia.util.UriUtil - -class InitialOnboardingFragment : OnboardingFragment(), OnboardingPageView.Callback { - private var onboardingPageView: OnboardingPageView? = null - override val doneButtonText = R.string.onboarding_get_started - var languageChanged = false - - private val loginLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - if (it.resultCode == LoginActivity.RESULT_LOGIN_SUCCESS) { - FeedbackUtil.showMessage(this, R.string.login_success_toast) - advancePage() - } - } - - private val languageChooserLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - languageChanged = true - } - - override fun getAdapter(): FragmentStateAdapter { - return OnboardingPagerAdapter(this) - } - - override fun onLinkClick(view: OnboardingPageView, url: String) { - when (url) { - "#login" -> loginLauncher.launch(LoginActivity.newIntent(requireContext(), LoginActivity.SOURCE_ONBOARDING)) - "#privacy" -> FeedbackUtil.showPrivacyPolicy(requireContext()) - "#about" -> FeedbackUtil.showAboutWikipedia(requireContext()) - "#offline" -> FeedbackUtil.showOfflineReadingAndData(requireContext()) - "#termsOfUse" -> FeedbackUtil.showTermsOfUse(requireContext()) - else -> UriUtil.handleExternalLink(requireActivity(), Uri.parse(url)) - } - } - - override fun onListActionButtonClicked(view: OnboardingPageView) { - onboardingPageView = view - languageChooserLauncher.launch(WikipediaLanguagesActivity.newIntent(requireContext(), Constants.InvokeSource.ONBOARDING_DIALOG)) - } - - override fun onResume() { - super.onResume() - onboardingPageView?.refreshLanguageList() - } - - private class OnboardingPagerAdapter(fragment: Fragment) : FragmentStateAdapter(fragment) { - override fun createFragment(position: Int): Fragment { - return ItemFragment.newInstance(position) - } - - override fun getItemCount(): Int { - return OnboardingPage.entries.size - } - } - - class ItemFragment : Fragment() { - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - super.onCreateView(inflater, container, savedInstanceState) - val position = requireArguments().getInt("position", 0) - val view = inflater.inflate(OnboardingPage.of(position).layout, container, false) as OnboardingPageView - view.tag = position - view.callback = callback - return view - } - - private val callback - get() = FragmentUtil.getCallback(this, OnboardingPageView.Callback::class.java) - - companion object { - fun newInstance(position: Int): ItemFragment { - return ItemFragment().apply { arguments = bundleOf("position" to position) } - } - } - } - - @Suppress("unused") - internal enum class OnboardingPage(@LayoutRes val layout: Int) : EnumCode { - PAGE_WELCOME(R.layout.inflate_initial_onboarding_page_zero), - PAGE_EXPLORE(R.layout.inflate_initial_onboarding_page_one), - PAGE_READING_LISTS(R.layout.inflate_initial_onboarding_page_two), - PAGE_DATA_PRIVACY(R.layout.inflate_initial_onboarding_page_three); - - override fun code(): Int { - return ordinal - } - - companion object { - fun of(code: Int): OnboardingPage { - return entries[code] - } - } - } - - companion object { - fun newInstance(): InitialOnboardingFragment { - return InitialOnboardingFragment() - } - } -} diff --git a/app/src/main/java/org/wikipedia/onboarding/InitialOnboardingScreen.kt b/app/src/main/java/org/wikipedia/onboarding/InitialOnboardingScreen.kt new file mode 100644 index 00000000000..46104aadc13 --- /dev/null +++ b/app/src/main/java/org/wikipedia/onboarding/InitialOnboardingScreen.kt @@ -0,0 +1,464 @@ +package org.wikipedia.onboarding + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +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.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLinkStyles +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil3.compose.SubcomposeAsyncImage +import coil3.compose.SubcomposeAsyncImageContent +import coil3.request.ImageRequest +import coil3.request.allowHardware +import org.wikipedia.R +import org.wikipedia.compose.components.HtmlText +import org.wikipedia.compose.extensions.lazyColumnScrollbar +import org.wikipedia.compose.theme.BaseTheme +import org.wikipedia.compose.theme.WikipediaTheme +import org.wikipedia.language.AppLanguageState +import org.wikipedia.theme.Theme +import org.wikipedia.util.FeedbackUtil +import org.wikipedia.yearinreview.LoadingIndicator + +@Composable +fun InitialOnboardingScreen( + modifier: Modifier = Modifier, + onboardingScreens: List, + languageState: AppLanguageState? = null, + appLanguageCodes: List, + onAddLanguageClick: () -> Unit, + onNextClick: () -> Unit, + onFinishClick: () -> Unit +) { + val context = LocalContext.current + var currentScreenIndex by remember { mutableIntStateOf(0) } + Scaffold( + modifier = modifier, + containerColor = WikipediaTheme.colors.paperColor, + bottomBar = { + Column( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + ) { + if (onboardingScreens[currentScreenIndex] != OnboardingScreen.INTRO) { + Spacer( + modifier = Modifier.height(1.dp) + .fillMaxWidth() + .background(WikipediaTheme.colors.borderColor) + ) + } + Box( + modifier = Modifier + .fillMaxWidth(), + contentAlignment = Alignment.CenterEnd + ) { + IconButton(onClick = { + if (currentScreenIndex == onboardingScreens.size - 1) { + onFinishClick() + } else { + currentScreenIndex++ + } + onNextClick() + }) { + Icon( + painter = painterResource(R.drawable.ic_arrow_forward_black_24dp), + tint = WikipediaTheme.colors.progressiveColor, + contentDescription = stringResource(R.string.nav_item_forward) + ) + } + } + } + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + ) { + when (onboardingScreens[currentScreenIndex]) { + OnboardingScreen.INTRO -> { + InitialOnboardingIntroContent( + onLearnMoreClick = { + FeedbackUtil.showAboutWikipedia(context) + } + ) + } + OnboardingScreen.DATA_PRIVACY -> { + InitialOnboardingDataPrivacyContent( + onPrivacyClick = { + FeedbackUtil.showPrivacyPolicy(context) + }, + onTermsClick = { + FeedbackUtil.showTermsOfUse(context) + } + ) + } + OnboardingScreen.LANGUAGES -> { + InitialOnboardingLanguagesScreen( + languageState = languageState, + appLanguageCodes = appLanguageCodes, + onAddLanguageClick = { + onAddLanguageClick() + } + ) + } + } + } + } +} + +@Composable +fun InitialOnboardingIntroContent( + modifier: Modifier = Modifier, + onLearnMoreClick: () -> Unit +) { + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Image( + modifier = Modifier + .padding(top = 24.dp, bottom = 16.dp) + .height(20.dp), + painter = painterResource(R.drawable.feed_header_wordmark), + contentDescription = stringResource(R.string.app_name_prod), + colorFilter = ColorFilter.tint(WikipediaTheme.colors.primaryColor), + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 16.dp), + text = stringResource(R.string.onboarding_fresh_install_knowledge_title), + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Medium, + color = WikipediaTheme.colors.primaryColor + ) + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + text = stringResource(R.string.onboarding_fresh_install_knowledge_text), + style = MaterialTheme.typography.bodyLarge, + color = WikipediaTheme.colors.primaryColor + ) + Text( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp) + .clickable(onClick = onLearnMoreClick), + text = stringResource(R.string.onboarding_fresh_install_knowledge_learn_more), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + color = WikipediaTheme.colors.progressiveColor + ) + + Spacer(modifier = Modifier.weight(1f)) + + Image( + modifier = Modifier + .fillMaxWidth(), + painter = painterResource(R.drawable.ic_onboarding_knowledge), + contentDescription = null, + contentScale = ContentScale.FillWidth + ) + } +} + +@Composable +fun InitialOnboardingDataPrivacyContent( + modifier: Modifier = Modifier, + onPrivacyClick: () -> Unit, + onTermsClick: () -> Unit +) { + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + + SubcomposeAsyncImage( + modifier = Modifier + .size(124.dp), + model = ImageRequest.Builder(LocalContext.current) + .data(R.drawable.ic_onboarding_puzzle) + .allowHardware(false) + .build(), + loading = { LoadingIndicator() }, + success = { + SubcomposeAsyncImageContent() + }, + contentDescription = stringResource(R.string.onboarding_data_privacy_title), + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 16.dp), + text = stringResource(R.string.onboarding_data_privacy_title), + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Medium, + color = WikipediaTheme.colors.primaryColor + ) + HtmlText( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + text = stringResource(R.string.onboarding_data_privacy_text), + style = MaterialTheme.typography.bodyLarge, + color = WikipediaTheme.colors.primaryColor, + linkStyle = TextLinkStyles( + style = SpanStyle( + color = WikipediaTheme.colors.progressiveColor, + fontSize = 16.sp, + fontWeight = FontWeight.Bold + ) + ), + linkInteractionListener = { + val url = (it as LinkAnnotation.Url).url + when { + url.contains("#privacy") -> onPrivacyClick() + url.contains("#termsOfUse") -> onTermsClick() + } + } + ) + } +} + +@Composable +fun InitialOnboardingLanguagesScreen( + modifier: Modifier = Modifier, + languageState: AppLanguageState?, + appLanguageCodes: List, + onAddLanguageClick: () -> Unit, +) { + val lazyListState = rememberLazyListState() + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.Center + ) { + + Box( + modifier = Modifier + .fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + SubcomposeAsyncImage( + modifier = Modifier + .size(124.dp), + model = ImageRequest.Builder(LocalContext.current) + .data(R.drawable.yir_puzzle_stone) + .allowHardware(false) + .build(), + loading = { LoadingIndicator() }, + success = { + SubcomposeAsyncImageContent() + }, + contentDescription = stringResource(R.string.onboarding_app_languages_title), + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 16.dp), + text = stringResource(R.string.onboarding_app_languages_title), + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Medium, + color = WikipediaTheme.colors.primaryColor + ) + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + text = stringResource(R.string.onboarding_app_languages_text), + style = MaterialTheme.typography.bodyLarge, + color = WikipediaTheme.colors.primaryColor + ) + + Spacer(modifier = Modifier.height(16.dp)) + + LazyColumn( + state = lazyListState, + modifier = Modifier + .fillMaxWidth() + .height(120.dp) + .padding(horizontal = 24.dp) + .lazyColumnScrollbar( + state = lazyListState, + color = WikipediaTheme.colors.inactiveColor + ), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + items(count = appLanguageCodes.size) { + val isPrimary = it == 0 + if (isPrimary) { + Spacer( + modifier = Modifier.height(0.5.dp) + .fillMaxWidth() + .background(WikipediaTheme.colors.borderColor) + ) + Spacer(modifier = Modifier.height(16.dp)) + } + InitialOnboardingLanguageItem( + languageState = languageState, + languageCode = appLanguageCodes[it], + isPrimary = isPrimary + ) + } + } + + Box( + modifier = Modifier + .padding(horizontal = 24.dp) + .clickable(onClick = onAddLanguageClick) + ) { + Text( + modifier = Modifier + .padding(vertical = 24.dp), + text = stringResource(R.string.onboarding_app_languages_add_button), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + color = WikipediaTheme.colors.progressiveColor, + ) + } + } +} + +@Composable +fun InitialOnboardingLanguageItem( + languageState: AppLanguageState?, + languageCode: String, + isPrimary: Boolean +) { + val localizedName = languageState?.getAppLanguageLocalizedName(languageCode) ?: languageCode + Text( + modifier = Modifier + .fillMaxWidth(), + text = localizedName, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + color = WikipediaTheme.colors.primaryColor + ) + if (isPrimary) { + Text( + modifier = Modifier + .fillMaxWidth(), + text = stringResource(R.string.onboarding_app_languages_primary), + style = MaterialTheme.typography.bodyMedium, + color = WikipediaTheme.colors.secondaryColor + ) + } + Spacer(modifier = Modifier.height(16.dp)) + Spacer( + modifier = Modifier + .height(0.5.dp) + .fillMaxWidth() + .background(WikipediaTheme.colors.borderColor) + ) +} + +@Preview(showBackground = true) +@Composable +fun InitialOnboardingScreenPreview() { + BaseTheme( + currentTheme = Theme.LIGHT + ) { + InitialOnboardingScreen( + modifier = Modifier.fillMaxSize(), + onboardingScreens = listOf(OnboardingScreen.INTRO, OnboardingScreen.DATA_PRIVACY, OnboardingScreen.LANGUAGES), + languageState = null, + appLanguageCodes = listOf("en", "es", "de"), + onAddLanguageClick = {}, + onNextClick = {}, + onFinishClick = {} + ) + } +} + +@Preview(showBackground = true, backgroundColor = 0xFF000000) +@Composable +fun InitialOnboardingIntroScreenPreview() { + BaseTheme( + currentTheme = Theme.DARK + ) { + InitialOnboardingIntroContent( + onLearnMoreClick = {} + ) + } +} + +@Preview(showBackground = true) +@Composable +fun InitialOnboardingDataPrivacyContentPreview() { + BaseTheme( + currentTheme = Theme.LIGHT + ) { + InitialOnboardingDataPrivacyContent( + onPrivacyClick = {}, + onTermsClick = {} + ) + } +} + +@Preview(showBackground = true) +@Composable +fun InitialOnboardingLanguagesLanguagesScreenPreview() { + BaseTheme( + currentTheme = Theme.LIGHT + ) { + InitialOnboardingLanguagesScreen( + languageState = null, + appLanguageCodes = listOf("en", "es", "de"), + onAddLanguageClick = {} + ) + } +} diff --git a/app/src/main/java/org/wikipedia/page/PageActivity.kt b/app/src/main/java/org/wikipedia/page/PageActivity.kt index 0ae6bb08d29..99208e9216f 100644 --- a/app/src/main/java/org/wikipedia/page/PageActivity.kt +++ b/app/src/main/java/org/wikipedia/page/PageActivity.kt @@ -324,7 +324,7 @@ class PageActivity : BaseActivity(), PageFragment.Callback, LinkPreviewDialog.Lo if (app.haveMainActivity) { onBackPressedDispatcher.onBackPressed() } else { - pageFragment.goToMainActivity(tab = NavTab.EXPLORE, tabExtra = Constants.INTENT_EXTRA_GO_TO_MAIN_TAB) + pageFragment.goToMainActivity(tab = NavTab.HOME, tabExtra = Constants.INTENT_EXTRA_GO_TO_MAIN_TAB) } true } else -> super.onOptionsItemSelected(item) diff --git a/app/src/main/java/org/wikipedia/page/PageFragment.kt b/app/src/main/java/org/wikipedia/page/PageFragment.kt index 50f47aa7bcb..0a27047c6a3 100644 --- a/app/src/main/java/org/wikipedia/page/PageFragment.kt +++ b/app/src/main/java/org/wikipedia/page/PageFragment.kt @@ -1460,9 +1460,9 @@ class PageFragment : Fragment(), BackPressedHandler, CommunicationBridge.Communi articleInteractionEvent?.logNewTabClick() } - override fun onExploreSelected() { - goToMainActivity(tab = NavTab.EXPLORE, tabExtra = Constants.INTENT_EXTRA_GO_TO_MAIN_TAB) - articleInteractionEvent?.logExploreClick() + override fun onHomeSelected() { + goToMainActivity(tab = NavTab.HOME, tabExtra = Constants.INTENT_EXTRA_GO_TO_MAIN_TAB) + articleInteractionEvent?.logHomeClick() } override fun onCategoriesSelected() { diff --git a/app/src/main/java/org/wikipedia/page/action/PageActionItem.kt b/app/src/main/java/org/wikipedia/page/action/PageActionItem.kt index 7346d006011..03de1884f1b 100644 --- a/app/src/main/java/org/wikipedia/page/action/PageActionItem.kt +++ b/app/src/main/java/org/wikipedia/page/action/PageActionItem.kt @@ -63,9 +63,9 @@ enum class PageActionItem constructor(val id: Int, cb.onNewTabSelected() } }, - EXPLORE(10, R.id.page_explore, R.string.feed, R.drawable.ic_globe) { + HOME(10, R.id.page_home, R.string.home, R.drawable.ic_home_filled_24dp) { override fun select(cb: Callback) { - cb.onExploreSelected() + cb.onHomeSelected() } }, CATEGORIES(11, R.id.page_categories, R.string.action_item_categories, R.drawable.ic_category_black_24dp) { @@ -103,7 +103,7 @@ enum class PageActionItem constructor(val id: Int, fun onViewTalkPageSelected() fun onViewEditHistorySelected() fun onNewTabSelected() - fun onExploreSelected() + fun onHomeSelected() fun onCategoriesSelected() fun onEditArticleSelected() fun onViewOnMapSelected() @@ -112,7 +112,7 @@ enum class PageActionItem constructor(val id: Int, companion object { val DEFAULT_TOOLBAR_LIST = listOf(SAVE, LANGUAGE, FIND_IN_ARTICLE, THEME, CONTENTS).map { it.id } - val DEFAULT_OVERFLOW_MENU_LIST = listOf(SHARE, ADD_TO_WATCHLIST, VIEW_TALK_PAGE, VIEW_EDIT_HISTORY, VIEW_ON_MAP, NEW_TAB, EXPLORE, CATEGORIES, EDIT_ARTICLE).map { it.id } + val DEFAULT_OVERFLOW_MENU_LIST = listOf(SHARE, ADD_TO_WATCHLIST, VIEW_TALK_PAGE, VIEW_EDIT_HISTORY, VIEW_ON_MAP, NEW_TAB, HOME, CATEGORIES, EDIT_ARTICLE).map { it.id } fun find(id: Int): PageActionItem { return entries.find { id == it.id || id == it.viewId } ?: entries[0] diff --git a/app/src/main/java/org/wikipedia/page/tabs/TabActivity.kt b/app/src/main/java/org/wikipedia/page/tabs/TabActivity.kt index b9009cbcec0..f9cd9b3ed65 100644 --- a/app/src/main/java/org/wikipedia/page/tabs/TabActivity.kt +++ b/app/src/main/java/org/wikipedia/page/tabs/TabActivity.kt @@ -116,7 +116,7 @@ class TabActivity : BaseActivity() { } true } - R.id.menu_explore -> { + R.id.menu_home -> { goToMainTab() true } @@ -177,7 +177,7 @@ class TabActivity : BaseActivity() { MainActivity.newIntent(this) .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) .putExtra(Constants.INTENT_RETURN_TO_MAIN, true) - .putExtra(Constants.INTENT_EXTRA_GO_TO_MAIN_TAB, NavTab.EXPLORE.code())) + .putExtra(Constants.INTENT_EXTRA_GO_TO_MAIN_TAB, NavTab.HOME.code())) finish() } diff --git a/app/src/main/java/org/wikipedia/readinglist/recommended/RecommendedReadingListInterestsFragment.kt b/app/src/main/java/org/wikipedia/readinglist/recommended/RecommendedReadingListInterestsFragment.kt index 55e7a3bcde5..ee6b9208941 100644 --- a/app/src/main/java/org/wikipedia/readinglist/recommended/RecommendedReadingListInterestsFragment.kt +++ b/app/src/main/java/org/wikipedia/readinglist/recommended/RecommendedReadingListInterestsFragment.kt @@ -11,12 +11,10 @@ import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut -import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement 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 @@ -31,8 +29,6 @@ import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan import androidx.compose.foundation.lazy.staggeredgrid.items import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.LinearProgressIndicator @@ -51,21 +47,15 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.graphics.painter.BrushPainter -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -73,13 +63,12 @@ import androidx.core.net.toUri import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope -import coil3.compose.AsyncImage import kotlinx.coroutines.launch import org.wikipedia.Constants import org.wikipedia.R import org.wikipedia.analytics.eventplatform.RecommendedReadingListEvent -import org.wikipedia.compose.components.HtmlText -import org.wikipedia.compose.components.WikiCard +import org.wikipedia.compose.components.ArticleCard +import org.wikipedia.compose.components.SearchBarCard import org.wikipedia.compose.components.WikipediaAlertDialog import org.wikipedia.compose.components.error.WikiErrorClickEvents import org.wikipedia.compose.components.error.WikiErrorView @@ -95,7 +84,6 @@ import org.wikipedia.settings.Prefs import org.wikipedia.theme.Theme import org.wikipedia.util.FeedbackUtil import org.wikipedia.util.Resource -import org.wikipedia.views.imageservice.ImageService class RecommendedReadingListInterestsFragment : Fragment() { private val viewModel: RecommendedReadingListInterestsViewModel by viewModels() @@ -362,10 +350,13 @@ fun RecommendedReadingListInterestsContent( ) } item(span = StaggeredGridItemSpan.FullLine) { - ReadingListInterestSearchCard(onSearchClick) + SearchBarCard( + text = stringResource(R.string.recommended_reading_list_interest_pick_search_hint), + onSearchClick = onSearchClick + ) } items(items) { item -> - ReadingListInterestCard( + ArticleCard( modifier = Modifier.animateItem(), item = item, isSelected = selectedItems.contains(item), @@ -441,113 +432,6 @@ fun RecommendedReadingListInterestsContent( } } -@Composable -fun ReadingListInterestCard( - modifier: Modifier, - item: PageTitle, - isSelected: Boolean = false, - onItemClick: (PageTitle) -> Unit = {}, -) { - WikiCard( - modifier = modifier - .fillMaxWidth(), - elevation = 0.dp, - border = BorderStroke(width = 1.dp, color = WikipediaTheme.colors.borderColor), - colors = CardDefaults.cardColors( - containerColor = if (isSelected) WikipediaTheme.colors.additionColor else WikipediaTheme.colors.paperColor - ), - shape = RoundedCornerShape(16.dp), - onClick = { - onItemClick(item) - } - ) { - Column( - modifier = Modifier - .fillMaxWidth() - ) { - if (!item.thumbUrl.isNullOrEmpty()) { - val request = ImageService.getRequest(LocalContext.current, url = item.thumbUrl, detectFace = true) - AsyncImage( - model = request, - placeholder = BrushPainter(SolidColor(WikipediaTheme.colors.borderColor)), - error = BrushPainter(SolidColor(WikipediaTheme.colors.borderColor)), - contentScale = ContentScale.Crop, - contentDescription = null, - modifier = Modifier - .fillMaxWidth() - .height(108.dp) - .clip(RoundedCornerShape(16.dp)) - ) - } - Column( - modifier = Modifier.padding(8.dp) - ) { - HtmlText( - text = item.displayText, - style = MaterialTheme.typography.bodyLarge, - color = WikipediaTheme.colors.primaryColor - ) - Spacer(modifier = Modifier.height(2.dp)) - Row { - if (!item.description.isNullOrEmpty()) { - HtmlText( - text = item.description.orEmpty(), - style = MaterialTheme.typography.bodyMedium, - color = WikipediaTheme.colors.secondaryColor, - maxLines = 3, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f) - ) - } else { - Spacer(modifier = Modifier.weight(1f)) - } - if (isSelected) { - Spacer(modifier = Modifier.width(8.dp)) - Icon( - modifier = Modifier.size(24.dp).align(Alignment.Bottom), - painter = painterResource(R.drawable.check_circle_24px), - tint = WikipediaTheme.colors.primaryColor, - contentDescription = null - ) - } else { - Spacer(modifier = Modifier.width(32.dp).height(24.dp)) - } - } - } - } - } -} - -@Composable -fun ReadingListInterestSearchCard(onSearchClick: () -> Unit) { - Row( - modifier = Modifier - .fillMaxWidth() - .height(56.dp) - .clip(RoundedCornerShape(28.dp)) - .background( - color = WikipediaTheme.colors.backgroundColor, - shape = RoundedCornerShape(24.dp) - ) - .clickable(onClick = onSearchClick), - verticalAlignment = Alignment.CenterVertically - ) { - Spacer(modifier = Modifier.width(16.dp)) - Icon( - painter = painterResource(R.drawable.outline_search_24), - contentDescription = stringResource(R.string.search_hint), - tint = WikipediaTheme.colors.secondaryColor, - modifier = Modifier.size(24.dp) - ) - Text( - modifier = Modifier.padding(start = 16.dp, end = 16.dp), - text = stringResource(R.string.recommended_reading_list_interest_pick_search_hint), - style = MaterialTheme.typography.bodyLarge, - color = WikipediaTheme.colors.primaryColor - ) - } -} - @Preview(showBackground = true) @Composable fun PreviewReadingListInterestsScreen() { diff --git a/app/src/main/java/org/wikipedia/settings/Prefs.kt b/app/src/main/java/org/wikipedia/settings/Prefs.kt index df7393742ca..58f763bcd22 100644 --- a/app/src/main/java/org/wikipedia/settings/Prefs.kt +++ b/app/src/main/java/org/wikipedia/settings/Prefs.kt @@ -14,6 +14,7 @@ import org.wikipedia.analytics.eventplatform.AppSessionEvent import org.wikipedia.dataclient.WikiSite import org.wikipedia.donate.DonationResult import org.wikipedia.donate.donationreminder.DonationReminderConfig +import org.wikipedia.feed.personalization.homepreference.HomePreferenceType import org.wikipedia.games.onthisday.OnThisDayGameNotificationState import org.wikipedia.json.JsonUtil import org.wikipedia.page.PageTitle @@ -880,4 +881,18 @@ object Prefs { var isGameStatsUnavailableSnackbarShown get() = PrefsIoUtil.getBoolean(R.string.preference_key_game_stats_snackbar_shown, false) set(value) = PrefsIoUtil.setBoolean(R.string.preference_key_game_stats_snackbar_shown, value) + + var isExploreFeedUpdatePromptShown + get() = PrefsIoUtil.getBoolean(R.string.preference_key_explore_feed_update_prompt_shown, false) + set(value) = PrefsIoUtil.setBoolean(R.string.preference_key_explore_feed_update_prompt_shown, value) + + var homeLanguageCode + get() = PrefsIoUtil.getString(R.string.preference_key_home_language_code, WikipediaApp.instance.appOrSystemLanguageCode)!! + set(value) = PrefsIoUtil.setString(R.string.preference_key_home_language_code, value) + + var homePreferenceSelection: HomePreferenceType + get() = PrefsIoUtil.getString(R.string.preference_key_home_preference_selection, null)?.let { + HomePreferenceType.valueOf(it) + } ?: HomePreferenceType.COMMUNITY + set(value) = PrefsIoUtil.setString(R.string.preference_key_home_preference_selection, value.name) } diff --git a/app/src/main/java/org/wikipedia/settings/SettingsPreferenceLoader.kt b/app/src/main/java/org/wikipedia/settings/SettingsPreferenceLoader.kt index 5921008a144..1ac1f7de67a 100644 --- a/app/src/main/java/org/wikipedia/settings/SettingsPreferenceLoader.kt +++ b/app/src/main/java/org/wikipedia/settings/SettingsPreferenceLoader.kt @@ -45,7 +45,7 @@ internal class SettingsPreferenceLoader(fragment: PreferenceFragmentCompat) : Ba Constants.ACTIVITY_REQUEST_ADD_A_LANGUAGE) true } - findPreference(R.string.preference_key_customize_explore_feed).onPreferenceClickListener = Preference.OnPreferenceClickListener { + findPreference(R.string.preference_key_customize_home_feed).onPreferenceClickListener = Preference.OnPreferenceClickListener { activity.startActivityForResult( ConfigureActivity.newIntent(activity, Constants.InvokeSource.NAV_MENU.ordinal), Constants.ACTIVITY_REQUEST_FEED_CONFIGURE) diff --git a/app/src/main/java/org/wikipedia/settings/dev/DeveloperSettingsPreferenceLoader.kt b/app/src/main/java/org/wikipedia/settings/dev/DeveloperSettingsPreferenceLoader.kt index 32c7e0421a7..5a1d65207f3 100644 --- a/app/src/main/java/org/wikipedia/settings/dev/DeveloperSettingsPreferenceLoader.kt +++ b/app/src/main/java/org/wikipedia/settings/dev/DeveloperSettingsPreferenceLoader.kt @@ -18,6 +18,7 @@ import org.wikipedia.WikipediaApp import org.wikipedia.database.AppDatabase import org.wikipedia.dataclient.WikiSite import org.wikipedia.donate.donationreminder.DonationReminderConfig +import org.wikipedia.feed.personalization.homepreference.HomePreferenceType import org.wikipedia.games.onthisday.OnThisDayGameNotificationManager import org.wikipedia.games.onthisday.OnThisDayGameNotificationState import org.wikipedia.history.HistoryEntry @@ -268,6 +269,23 @@ internal class DeveloperSettingsPreferenceLoader(fragment: PreferenceFragmentCom findPreference(R.string.preference_key_event_platform_intake_base_uri).summary = selectedState true } + (findPreference(R.string.preference_key_home_preference_selection) as ListPreference).apply { + value = Prefs.homePreferenceSelection.name + val states = HomePreferenceType.entries + val names = states.map { it.name }.toTypedArray() + entries = names + entryValues = names + setOnPreferenceChangeListener { _, newValue -> + val selectedState = newValue as String + val source = when (selectedState) { + "COMMUNITY" -> HomePreferenceType.COMMUNITY + "PERSONALIZED" -> HomePreferenceType.PERSONALIZED + else -> HomePreferenceType.COMMUNITY + } + Prefs.homePreferenceSelection = source + true + } + } } private fun setUpMediaWikiSettings() { diff --git a/app/src/main/java/org/wikipedia/util/DeviceUtil.kt b/app/src/main/java/org/wikipedia/util/DeviceUtil.kt index 7fd74887650..f856ee62db2 100644 --- a/app/src/main/java/org/wikipedia/util/DeviceUtil.kt +++ b/app/src/main/java/org/wikipedia/util/DeviceUtil.kt @@ -17,7 +17,6 @@ import android.view.inputmethod.InputMethodManager import androidx.activity.enableEdgeToEdge import androidx.annotation.ColorInt import androidx.appcompat.app.AppCompatActivity -import androidx.core.content.ContextCompat import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import com.google.android.material.appbar.MaterialToolbar @@ -53,16 +52,14 @@ object DeviceUtil { resources.configuration.keyboard != Configuration.KEYBOARD_NOKEYS) } - fun setLightSystemUiVisibility(activity: Activity) { - // this make the system recognizes the status bar light and will make status bar icons become visible - // if the theme is not dark - activity.window.insetsControllerCompat.isAppearanceLightStatusBars = !WikipediaApp.instance.currentTheme.isDark + fun setLightSystemUiVisibility(activity: Activity, light: Boolean = !WikipediaApp.instance.currentTheme.isDark) { + activity.window.insetsControllerCompat.isAppearanceLightStatusBars = light + activity.window.insetsControllerCompat.isAppearanceLightNavigationBars = light } fun setNavigationBarColor(window: Window, @ColorInt color: Int) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val isDarkThemeOrDarkBackground = WikipediaApp.instance.currentTheme.isDark || - color == ContextCompat.getColor(window.context, android.R.color.black) + val isDarkThemeOrDarkBackground = WikipediaApp.instance.currentTheme.isDark || color == Color.BLACK window.navigationBarColor = color window.insetsControllerCompat.isAppearanceLightNavigationBars = !isDarkThemeOrDarkBackground } diff --git a/app/src/main/res/drawable/ic_download_24px.xml b/app/src/main/res/drawable/ic_download_24px.xml new file mode 100644 index 00000000000..dba46014447 --- /dev/null +++ b/app/src/main/res/drawable/ic_download_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_dynamic_feed_24dp.xml b/app/src/main/res/drawable/ic_dynamic_feed_24dp.xml new file mode 100644 index 00000000000..b21f3fa7d5e --- /dev/null +++ b/app/src/main/res/drawable/ic_dynamic_feed_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_home_24dp.xml b/app/src/main/res/drawable/ic_home_24dp.xml new file mode 100644 index 00000000000..9f211b8df6e --- /dev/null +++ b/app/src/main/res/drawable/ic_home_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_home_filled_24dp.xml b/app/src/main/res/drawable/ic_home_filled_24dp.xml new file mode 100644 index 00000000000..988a92378e9 --- /dev/null +++ b/app/src/main/res/drawable/ic_home_filled_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_onboarding_knowledge.png b/app/src/main/res/drawable/ic_onboarding_knowledge.png new file mode 100644 index 00000000000..125438c265b Binary files /dev/null and b/app/src/main/res/drawable/ic_onboarding_knowledge.png differ diff --git a/app/src/main/res/drawable/ic_onboarding_puzzle.gif b/app/src/main/res/drawable/ic_onboarding_puzzle.gif new file mode 100644 index 00000000000..daf793cd745 Binary files /dev/null and b/app/src/main/res/drawable/ic_onboarding_puzzle.gif differ diff --git a/app/src/main/res/drawable/ic_public_24dp.xml b/app/src/main/res/drawable/ic_public_24dp.xml new file mode 100644 index 00000000000..2df31065587 --- /dev/null +++ b/app/src/main/res/drawable/ic_public_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_split_scene_24dp.xml b/app/src/main/res/drawable/ic_split_scene_24dp.xml new file mode 100644 index 00000000000..481d9724896 --- /dev/null +++ b/app/src/main/res/drawable/ic_split_scene_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_trending_down_24dp.xml b/app/src/main/res/drawable/ic_trending_down_24dp.xml new file mode 100644 index 00000000000..7ee609f1b29 --- /dev/null +++ b/app/src/main/res/drawable/ic_trending_down_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_trending_up_24dp.xml b/app/src/main/res/drawable/ic_trending_up_24dp.xml new file mode 100644 index 00000000000..b305e073d6c --- /dev/null +++ b/app/src/main/res/drawable/ic_trending_up_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/fragment_main.xml b/app/src/main/res/layout/fragment_main.xml index 79bf7c55173..d720d29d786 100644 --- a/app/src/main/res/layout/fragment_main.xml +++ b/app/src/main/res/layout/fragment_main.xml @@ -28,23 +28,30 @@ android:background="?attr/border_color" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintBottom_toTopOf="@id/main_nav_tab_layout"/> + app:layout_constraintBottom_toTopOf="@id/main_nav_tab_container"/> - + app:layout_constraintBottom_toBottomOf="parent"> + + + + diff --git a/app/src/main/res/layout/inflate_initial_onboarding_page_one.xml b/app/src/main/res/layout/inflate_initial_onboarding_page_one.xml deleted file mode 100644 index a33cddc37e0..00000000000 --- a/app/src/main/res/layout/inflate_initial_onboarding_page_one.xml +++ /dev/null @@ -1,10 +0,0 @@ - - \ No newline at end of file diff --git a/app/src/main/res/layout/inflate_initial_onboarding_page_three.xml b/app/src/main/res/layout/inflate_initial_onboarding_page_three.xml deleted file mode 100644 index 52b7d9393c4..00000000000 --- a/app/src/main/res/layout/inflate_initial_onboarding_page_three.xml +++ /dev/null @@ -1,10 +0,0 @@ - - \ No newline at end of file diff --git a/app/src/main/res/layout/inflate_initial_onboarding_page_two.xml b/app/src/main/res/layout/inflate_initial_onboarding_page_two.xml deleted file mode 100644 index 2c64458cd2f..00000000000 --- a/app/src/main/res/layout/inflate_initial_onboarding_page_two.xml +++ /dev/null @@ -1,10 +0,0 @@ - - \ No newline at end of file diff --git a/app/src/main/res/layout/inflate_initial_onboarding_page_zero.xml b/app/src/main/res/layout/inflate_initial_onboarding_page_zero.xml deleted file mode 100644 index 78808edbce0..00000000000 --- a/app/src/main/res/layout/inflate_initial_onboarding_page_zero.xml +++ /dev/null @@ -1,12 +0,0 @@ - - \ No newline at end of file diff --git a/app/src/main/res/menu/menu_tabs.xml b/app/src/main/res/menu/menu_tabs.xml index eda3db66297..5c30b869fc5 100644 --- a/app/src/main/res/menu/menu_tabs.xml +++ b/app/src/main/res/menu/menu_tabs.xml @@ -10,7 +10,7 @@ - diff --git a/app/src/main/res/values-qq/strings.xml b/app/src/main/res/values-qq/strings.xml index dd08b04c600..5cc27ea9213 100644 --- a/app/src/main/res/values-qq/strings.xml +++ b/app/src/main/res/values-qq/strings.xml @@ -430,8 +430,10 @@ Button to obtain directions to the location specified in the link preview. Error displayed when the device does not have any apps installed that are capable of providing directions to a location. Title of the preference for customizing the explore feed. + Title of the preference for customizing the home feed. Title of the preference for enabling or disabling link previews.\n\nShown in the preferences screen along with the following:\n* {{msg-wikimedia|Wikipedia-android-strings-preference title show images}}\n* {{msg-wikimedia|Wikipedia-android-strings-preference title language}}\n* {{msg-wikimedia|Wikipedia-android-strings-preference title show link previews}}\n* {{msg-wikimedia|Wikipedia-android-strings-preference title eventlogging opt in}}\n* {{msg-wikimedia|Wikipedia-android-strings-zero warn when leaving}} Description of the preference for customizing the explore feed. + Description of the preference for customizing the home feed. Description of the preference for enabling or disabling link previews. Title of the preference for enabling or disabling automatic collapsing of infoboxes and other tables. Description of the preference for enabling or disabling automatic collapsing of infoboxes and other tables. @@ -838,9 +840,11 @@ Title of a card that the user can click to view a randomly selected Wikipedia article Label of a action button that leads the user to the Randomizer page. Feed card title for recently popular articles.\n{{Identical|Top read}} + Feed card description for recently popular articles. Button label to navigate to a full-screen view containing more trending articles. Text for showing the amount of pageviews in thousands. \"%d\" represents the number of pageviews in thousands. Text for showing the amount of pageviews in millions. \"%d\" represents the number of pageviews in millions. + Suffix text for the amount of pageviews. Button hint for loading another random article. Title of a card that the user can click to view the main page of the Wikipedia project for the current app language. Button label for navigating to the Main Page of Wikipedia. @@ -1268,6 +1272,13 @@ Button text to suggest that users can now add more languages of their preference. Title for a tutorial screen about the free encyclopedia and now the app is available in over 300 languages. Button label to continue to the next onboarding or tutorial screen. + Title text for onboarding screen related to the knowledge source of Wikipedia. + Message text for onboarding screen related to the knowledge source of Wikipedia. + Label text for onboarding screen related to the knowledge source of Wikipedia. + Title text for the onboarding screen to select the app languages. + Message text for the onboarding screen to select the app languages. + Description text for the onboarding screen that indicates the first language is the primary language in the app. + Button label for the onboarding screen to select app languages. A shortcut icon on the home screen for getting a random article.\n{{Identical|Random}} A shortcut icon on the home screen for continuing reading an article. A shortcut icon on the home screen for opening the search page.\n{{Identical|Search}} @@ -2202,4 +2213,34 @@ Title of a dialog box rewarding the user for completing the reading challenge. Body text of a dialog box rewarding the user for completing the reading challenge. Button label to navigate to the Wikipedia store with a discount, as a reward for completing the reading challenge. + Title text for the onboarding prompt annoncing the rename of the Explore Feed to Home. + Title for the slide explaining the feed\'s new name. + Body text explaining that the Explore feed has been renamed to Home and remains in the same location. + Title for the slide describing the two content tabs available in the Home feed. + Body text describing the two feed modes: personalized articles in For You, and editorially curated content in Community. + Title for the slide explaining about user control. + Body text explaining that each recommendation shows its reason, and that preferences can be updated in Settings. + Title for the slide describing per-language feed personalization. + Body text explaining that users reading in multiple languages each get a separately personalized feed, which updates automatically on language switch. + Label for the primary action button on the feed update onboarding prompt, directing the user to manually customize their feed. + Label for the secondary action button on the feed update onboarding prompt, allowing the user to have their feed configured automatically. + Body text for the feed building screen. + Label text for the explore feed type tab for the community content. + Date label at the top of the Home feed for the content of the current day. The %s symbol is replaced with the date. + Label text for the explore feed type tab for the personalized content. + Label text for the explore feed to load more content. + Display name for the title of the Explore feed onboarding screen prompting users to follow topics. + Body text for the Explore feed onboarding screen explaining feed personalization and data usage. \n\n represents a paragraph break between the personalization explanation and the data collection notice. + Button text to deselect all selected items in the intereset selection screen. + Description of the Picture of the Day card in the Home feed. + Description of the Featured Article card in the Home feed. + Screen title prompting the user to choose which type of content should appear first in the explore feed. + Option label for selecting community-related content in the preference selection screen. + Option label for selecting personalized content based on user interests in preference selection screen. + Description of the In the News card in the Home feed. + Description of the On This Day card in the Home feed. + Text for the disclaimer at the top of the community tab in the Home feed. + Label text for the community tab in Home feed to load more content. + Label text in the languages pop-up menu in the Home feed to manauge the app languages. + Message shown when no interests are selected, informing the user that they need to add interests to receive personalized content recommendations and guiding them to previous steps or Settings. diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index e350c3b19ae..7ec8d6b3011 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -47,6 +47,11 @@ #80FF9500 #FFC894 + #854848 + + #534FA3 + + #82456A #F8F9FA #FFFFFF diff --git a/app/src/main/res/values/ids.xml b/app/src/main/res/values/ids.xml index f126b8717a3..20c1e055477 100644 --- a/app/src/main/res/values/ids.xml +++ b/app/src/main/res/values/ids.xml @@ -1,6 +1,6 @@ - + @@ -16,7 +16,7 @@ - + diff --git a/app/src/main/res/values/preference_keys.xml b/app/src/main/res/values/preference_keys.xml index c72bb5ecc6d..a67f8195409 100644 --- a/app/src/main/res/values/preference_keys.xml +++ b/app/src/main/res/values/preference_keys.xml @@ -32,7 +32,7 @@ showDeveloperSettings %s-lastrun tabs - customizeExploreFeed + customizeHomeFeed showLinkPreviews collapseTables session_data @@ -208,4 +208,7 @@ hybridSearchOnboardingShown hybridSearchEnabled gameStatsSnackbarShown + exploreFeedUpdatePromptShown + homeLanguageCode + exploreFeedPreferenceSelection diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6a9ce0184b2..e25be7fbcec 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -400,8 +400,10 @@ Get directions Could not find any apps that provide directions. Explore feed + Home feed Show link previews Customize the Explore feed + Customize the Home feed Show a quick preview of articles when tapping on links. Collapse tables Automatically collapse tables in articles, such as infoboxes, references, and notes. @@ -833,9 +835,11 @@ Random article More random articles Top read + What is trending today on Wikipedia More top read %d k %d M + views Load another random article Today on Wikipedia View main page @@ -877,11 +881,11 @@ Generate random articles to read Suggestions to add content to Wikipedia Nearby articles based on the current location - There\'s nothing on your Explore feed + There\'s nothing on your Home feed Customize OK Cancel - Customize your Explore feed

You can now choose what to show on your feed, and also prioritize your favorite types of content.]]>
+ Customize your Home feed

You can now choose what to show on your feed, and also prioritize your favorite types of content.]]>
OK Cancel You have reached the bottom of the feed. @@ -1311,17 +1315,24 @@ Get started Maybe later New ways to explore - Customize the feed to your interests – whether it\'s learning about historical events On this day, or rolling the dice with Random.]]> + Customize the feed to your interests – whether it\'s learning about historical events On this day, or rolling the dice with Random.]]> Reading lists with sync Login to your Wikipedia account to sync your reading lists. Join Wikipedia]]> Data & Privacy - privacy policy and terms of use.]]> +
Learn more about our privacy policy and terms of use.]]>
Send usage data Got it We\'ve found the following on your device: Add or edit languages The Free Encyclopedia\n…in over 300 languages Next + All the world\'s knowledge + Wikipedia is a free online encyclopedia with 65 million articles collaboratively written and maintained in more than 300 languages by a community of volunteers. + Learn more about Wikipedia + Read in more than 300 languages + We\'ve noticed the following languages on your device: + Primary + Add or edit your languages @@ -1360,7 +1371,7 @@ Remove selected language? Remove selected languages? - You will only see content on the Explore feed and quick search filters for remaining Wikipedia languages. + You will only see content on the Home feed and quick search filters for remaining Wikipedia languages. Can\'t remove all languages Keep at least one Wikipedia language from which to search and read content. more @@ -1930,7 +1941,7 @@ Average score Current streak Articles referenced in today\'s game - Thank you for playing. A new game of \"Which came first?\" is available daily on the Explore feed. + Thank you for playing. A new game of \"Which came first?\" is available daily on the Home feed. Game notifications Learn more Problem with feature @@ -2453,4 +2464,36 @@ Curiosity looks good on you! 🛍️ Celebrate completing the challenge with 15%% off at the Wikipedia Store. Get 15%% off at the store + + + Explore is now Home, with a new look + A new name + The Explore feed is now Home, in the same spot as before. + Your feed, two ways + Discover articles personalized to your interests in For You, or browse Wikipedia\'s best editorial content in Community. + Stay in control + Every recommendation tells you exactly why it\'s there. You can update your preferences anytime in Settings. + Every language, fully personalized + Reading in multiple languages? Each one gets its own experience. Just switch and your new feed is ready. + Customize my feed + Set it up for me + Let\'s build your feed… + Community + For you + Today - %s + Load previous day + Follow your curiosity + Select topics that interest you, and we will personalize your feed.\n\nWe collect minimal data that is anonymized. + Deselect all + Daily images on Wikimedia Commons, selected by volunteer contributors + Featured articles are some of the best articles on Wikipedia and they are updated daily + What would you like to see first? + Community-related content + Personalized content + Articles that have been substantially updated to reflect recent or current events of wide interest + Discover historical events from this day + Content and resources selected by and about the Wikimedia community + See past community content + Manage languages + You need to add interests to see personalized content recommendations. You can do this in the previous steps or later in Settings. \ No newline at end of file diff --git a/app/src/main/res/xml/developer_preferences.xml b/app/src/main/res/xml/developer_preferences.xml index 8a99ba4fd94..d6cca0f2dac 100644 --- a/app/src/main/res/xml/developer_preferences.xml +++ b/app/src/main/res/xml/developer_preferences.xml @@ -576,4 +576,13 @@ android:key="@string/preference_key_hybrid_search_enabled" android:title="@string/preference_key_hybrid_search_enabled" /> + + + + + diff --git a/app/src/main/res/xml/preferences.xml b/app/src/main/res/xml/preferences.xml index b0532b3999e..a0bdc040896 100644 --- a/app/src/main/res/xml/preferences.xml +++ b/app/src/main/res/xml/preferences.xml @@ -11,9 +11,9 @@ android:title="@string/preference_title_language"> + android:key="@string/preference_key_customize_home_feed" + android:summary="@string/preference_summary_customize_home_feed" + android:title="@string/preference_title_customize_home_feed" /> + assertNull(article.topicId) + assertNull(article.topicLang) + } + } + + @Test + fun updateTopic_reassignsArticleToNewTopic() = runBlocking { + val sportsTopic = InterestTopic(topicId = "sports", lang = "en", topicLabel = "Sports", queryTopicId = "sports") + interestTopicDao.insert(sportsTopic) + + val technologyTopic = InterestTopic(topicId = "technology", lang = "en", topicLabel = "Technology", queryTopicId = "technology") + interestTopicDao.insert(technologyTopic) + + val article = InterestArticle(apiTitle = "John Doe", lang = "en", namespace = Namespace.MAIN, displayTitle = "John Doe", description = "Soccer Player", thumbUrl = "", topicId = "sports", topicLang = "en") + interestArticleDao.insert(article) + + interestArticleDao.updateTopic(newTopicId = technologyTopic.topicId, apiTitle = article.apiTitle, lang = article.lang, namespace = article.namespace) + val results = interestArticleDao.getAll("en").first() + assertTrue(results.topicId == technologyTopic.topicId) + } + + @Test + fun updateTopic_doesNotAffectOtherArticles() = runBlocking { + val sportsTopic = InterestTopic(topicId = "sports", lang = "en", topicLabel = "Sports", queryTopicId = "sports") + interestTopicDao.insert(sportsTopic) + + val technologyTopic = InterestTopic(topicId = "technology", lang = "en", topicLabel = "Technology", queryTopicId = "technology") + interestTopicDao.insert(technologyTopic) + + val article1 = InterestArticle(apiTitle = "John Doe", lang = "en", namespace = Namespace.MAIN, displayTitle = "John Doe", description = "Soccer Player", thumbUrl = "", topicId = sportsTopic.topicId, topicLang = sportsTopic.lang) + val article2 = InterestArticle(apiTitle = "Jane Doe", lang = "en", namespace = Namespace.MAIN, displayTitle = "Jane Doe", description = "Volleyball Player", thumbUrl = "", topicId = sportsTopic.topicId, topicLang = sportsTopic.lang) + interestArticleDao.insert(article1) + interestArticleDao.insert(article2) + + interestArticleDao.updateTopic(newTopicId = technologyTopic.topicId, apiTitle = "John Doe", lang = "en", namespace = Namespace.MAIN) + + val results = interestArticleDao.getAll("en") + assert(results.size == 2) + val johnDoeArticle = results.firstOrNull { it.apiTitle == "John Doe" } + val janeDoeArticle = results.firstOrNull { it.apiTitle == "Jane Doe" } + assertTrue(johnDoeArticle?.topicId == technologyTopic.topicId) + assertTrue(janeDoeArticle?.topicId == "sports") + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d05f87fb057..637ed96006c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -50,6 +50,7 @@ workRuntimeKtx = "2.11.2" composeBom = "2026.04.01" composeActivity = "1.13.0" composeViewModel = "2.10.0" +lottieCompose = "6.7.1" [libraries] @@ -122,6 +123,7 @@ compose-activity = { module = "androidx.activity:activity-compose", version.ref compose-view-model = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "composeViewModel" } compose-test = { module = "androidx.compose.ui:ui-test-junit4" } compose-debug-test = { module = "androidx.compose.ui:ui-test-manifest" } +lottie-compose = { module = "com.airbnb.android:lottie-compose", version.ref = "lottieCompose" } [plugins] android-application = { id = "com.android.application", version.ref = "gradle" }