mirror of
https://github.com/pepaskopec-blip/Graduately.git
synced 2026-10-27 15:14:35 +00:00
Show a what's-new card on the home screen after each update.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
dd8d992bf8
commit
6556c6691b
21 changed files with 490 additions and 0 deletions
|
|
@ -78,6 +78,9 @@ add_custom_command(TARGET maturita POST_BUILD
|
||||||
COMMAND ${CMAKE_COMMAND} -E copy_if_different
|
COMMAND ${CMAKE_COMMAND} -E copy_if_different
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/data/style.css
|
${CMAKE_CURRENT_SOURCE_DIR}/data/style.css
|
||||||
$<TARGET_FILE_DIR:maturita>/style.css
|
$<TARGET_FILE_DIR:maturita>/style.css
|
||||||
|
COMMAND ${CMAKE_COMMAND} -E copy_if_different
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/data/changelog.txt
|
||||||
|
$<TARGET_FILE_DIR:maturita>/changelog.txt
|
||||||
COMMAND ${CMAKE_COMMAND} -E make_directory
|
COMMAND ${CMAKE_COMMAND} -E make_directory
|
||||||
$<TARGET_FILE_DIR:maturita>/icons/hicolor/512x512/apps
|
$<TARGET_FILE_DIR:maturita>/icons/hicolor/512x512/apps
|
||||||
COMMAND ${CMAKE_COMMAND} -E copy_if_different
|
COMMAND ${CMAKE_COMMAND} -E copy_if_different
|
||||||
|
|
|
||||||
1
Makefile
1
Makefile
|
|
@ -83,6 +83,7 @@ bundle: $(TARGET)
|
||||||
@mkdir -p dist
|
@mkdir -p dist
|
||||||
@cp $(TARGET) dist/
|
@cp $(TARGET) dist/
|
||||||
@cp $(DATA_DIR)/style.css dist/
|
@cp $(DATA_DIR)/style.css dist/
|
||||||
|
@cp $(DATA_DIR)/changelog.txt dist/
|
||||||
@mkdir -p dist/icons/hicolor/512x512/apps
|
@mkdir -p dist/icons/hicolor/512x512/apps
|
||||||
@cp "$(ICON_DST)" dist/icons/hicolor/512x512/apps/
|
@cp "$(ICON_DST)" dist/icons/hicolor/512x512/apps/
|
||||||
@cp -R $(DATA_DIR)/share dist/
|
@cp -R $(DATA_DIR)/share dist/
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,8 @@ class AppViewModel(app: Application) : AndroidViewModel(app) {
|
||||||
private set
|
private set
|
||||||
var update by mutableStateOf(UpdateState())
|
var update by mutableStateOf(UpdateState())
|
||||||
private set
|
private set
|
||||||
|
var showChangelog by mutableStateOf(shouldShowChangelog())
|
||||||
|
private set
|
||||||
|
|
||||||
private val stack = mutableStateListOf<Route>(Route.Subjects)
|
private val stack = mutableStateListOf<Route>(Route.Subjects)
|
||||||
val route: Route get() = stack.last()
|
val route: Route get() = stack.last()
|
||||||
|
|
@ -146,4 +148,21 @@ class AppViewModel(app: Application) : AndroidViewModel(app) {
|
||||||
fun installUpdate() {
|
fun installUpdate() {
|
||||||
updater.install { update = it }
|
updater.install { update = it }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun dismissChangelog() {
|
||||||
|
progress.seenCommit = BuildConfig.COMMIT
|
||||||
|
showChangelog = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun shouldShowChangelog(): Boolean {
|
||||||
|
if (content.changelog.isEmpty()) return false
|
||||||
|
return !sameBuild(BuildConfig.COMMIT, progress.seenCommit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun sameBuild(a: String?, b: String?): Boolean {
|
||||||
|
if (a.isNullOrEmpty() || b.isNullOrEmpty()) return false
|
||||||
|
if (a.equals(b, ignoreCase = true)) return true
|
||||||
|
val n = minOf(a.length, b.length)
|
||||||
|
return n >= 7 && a.regionMatches(0, b, 0, n, ignoreCase = true)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ class Content(root: JSONObject) {
|
||||||
val hw = raw.arr("hw")
|
val hw = raw.arr("hw")
|
||||||
val mluvnice = raw.arr("mluvnice")
|
val mluvnice = raw.arr("mluvnice")
|
||||||
val books = raw.arr("books")
|
val books = raw.arr("books")
|
||||||
|
val changelog = raw.arr("changelog")
|
||||||
|
|
||||||
fun germanUnit(id: Int) = german.firstOrNull { it.int("id") == id }
|
fun germanUnit(id: Int) = german.firstOrNull { it.int("id") == id }
|
||||||
fun netLesson(id: Int) = netLessons.firstOrNull { it.int("id") == id }
|
fun netLesson(id: Int) = netLessons.firstOrNull { it.int("id") == id }
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,10 @@ class ProgressStore(context: Context) {
|
||||||
get() = if (prefs.getString("lang", "cs") == "en") UiLang.En else UiLang.Cs
|
get() = if (prefs.getString("lang", "cs") == "en") UiLang.En else UiLang.Cs
|
||||||
set(v) { prefs.edit().putString("lang", if (v == UiLang.En) "en" else "cs").apply() }
|
set(v) { prefs.edit().putString("lang", if (v == UiLang.En) "en" else "cs").apply() }
|
||||||
|
|
||||||
|
var seenCommit: String
|
||||||
|
get() = prefs.getString("seen_commit", "") ?: ""
|
||||||
|
set(v) { prefs.edit().putString("seen_commit", v).apply() }
|
||||||
|
|
||||||
fun germanDone(unit: Int, ex: Int) = prefs.getBoolean("g.$unit.$ex", false)
|
fun germanDone(unit: Int, ex: Int) = prefs.getBoolean("g.$unit.$ex", false)
|
||||||
fun markGerman(unit: Int, ex: Int) { prefs.edit().putBoolean("g.$unit.$ex", true).apply() }
|
fun markGerman(unit: Int, ex: Int) { prefs.edit().putBoolean("g.$unit.$ex", true).apply() }
|
||||||
fun vocabDone(unit: Int) = prefs.getBoolean("g.$unit.vocab", false)
|
fun vocabDone(unit: Int) = prefs.getBoolean("g.$unit.vocab", false)
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,9 @@ fun HomeScreen(vm: AppViewModel) {
|
||||||
val sum = summarize(vm.content, vm.progress)
|
val sum = summarize(vm.content, vm.progress)
|
||||||
val pct = if (sum.totalEx == 0) 0f else sum.doneEx / sum.totalEx.toFloat()
|
val pct = if (sum.totalEx == 0) 0f else sum.doneEx / sum.totalEx.toFloat()
|
||||||
RootScaffold(vm, "Maturita") {
|
RootScaffold(vm, "Maturita") {
|
||||||
|
if (vm.showChangelog) {
|
||||||
|
item { ChangelogCard(vm) }
|
||||||
|
}
|
||||||
item {
|
item {
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
|
@ -141,6 +144,51 @@ fun HomeScreen(vm: AppViewModel) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ChangelogCard(vm: AppViewModel) {
|
||||||
|
val langKey = if (vm.lang == UiLang.En) "en" else "cs"
|
||||||
|
val blocks = vm.content.changelog.take(3)
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.tertiaryContainer),
|
||||||
|
) {
|
||||||
|
Column(Modifier.padding(20.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
vm.tr("changelog_title"),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
val date = blocks.firstOrNull()?.str("date").orEmpty()
|
||||||
|
if (date.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
date,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onTertiaryContainer.copy(alpha = 0.7f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
blocks.forEachIndexed { index, block ->
|
||||||
|
if (index > 0) Spacer(Modifier.height(8.dp))
|
||||||
|
block.strs(langKey).ifEmpty { block.strs("cs") }.forEach { line ->
|
||||||
|
Text(
|
||||||
|
"• $line",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||||
|
modifier = Modifier.padding(bottom = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
FilledTonalButton(onClick = { vm.dismissChangelog() }) {
|
||||||
|
Text(vm.tr("changelog_dismiss"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun UpdateBanner(vm: AppViewModel) {
|
private fun UpdateBanner(vm: AppViewModel) {
|
||||||
Card(
|
Card(
|
||||||
|
|
|
||||||
15
data/changelog.txt
Normal file
15
data/changelog.txt
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
# User-facing notes, newest first. Copied next to style.css in every
|
||||||
|
# package and folded into content.json for Android and iOS.
|
||||||
|
# After an update the home screen shows the latest blocks until dismissed.
|
||||||
|
#
|
||||||
|
# --- YYYY-MM-DD
|
||||||
|
# cs: bullet
|
||||||
|
# en: bullet
|
||||||
|
|
||||||
|
--- 2026-09-18
|
||||||
|
cs: Po aktualizaci se na hlavní stránce ukáže, co je nového
|
||||||
|
cs: Android updater teď nainstaluje podepsané APK ze stejného klíče
|
||||||
|
cs: iOS se otevírá z Maturita.xcworkspace a běží nativně na iOS 26
|
||||||
|
en: After an update the home screen shows what is new
|
||||||
|
en: The Android updater now installs a signed APK from a stable key
|
||||||
|
en: iOS opens from Maturita.xcworkspace and runs natively on iOS 26
|
||||||
|
|
@ -159,6 +159,42 @@ tooltip {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
.changelog-card {
|
||||||
|
background-color: alpha(@accent, 0.10);
|
||||||
|
border: 1px solid alpha(@accent, 0.28);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 20px 24px 16px 24px;
|
||||||
|
}
|
||||||
|
.changelog-title {
|
||||||
|
color: @fg_text;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.3px;
|
||||||
|
}
|
||||||
|
.changelog-date {
|
||||||
|
color: @fg_subtext;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.changelog-item {
|
||||||
|
color: @fg_text;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
.changelog-dismiss {
|
||||||
|
background-image: none;
|
||||||
|
background-color: @accent;
|
||||||
|
color: @on_accent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: none;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
.changelog-dismiss:hover {
|
||||||
|
background-color: shade(@accent, 1.1);
|
||||||
|
}
|
||||||
.subject-grid {
|
.subject-grid {
|
||||||
padding: 8px 4px 24px 4px;
|
padding: 8px 4px 24px 4px;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,11 +14,13 @@ final class AppModel: ObservableObject {
|
||||||
@Published var searchQuery = ""
|
@Published var searchQuery = ""
|
||||||
@Published var tick = 0
|
@Published var tick = 0
|
||||||
@Published var update = UpdateState()
|
@Published var update = UpdateState()
|
||||||
|
@Published var showChangelog = false
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
themeId = progress.themeId
|
themeId = progress.themeId
|
||||||
mode = progress.mode
|
mode = progress.mode
|
||||||
lang = progress.lang
|
lang = progress.lang
|
||||||
|
showChangelog = !content.changelog.isEmpty && !sameBuild(AppConfig.commit, progress.seenCommit)
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
// `xcrun simctl launch <udid> org.maturita.maturita -route u1e3` opens a page for screenshots.
|
// `xcrun simctl launch <udid> org.maturita.maturita -route u1e3` opens a page for screenshots.
|
||||||
if let page = UserDefaults.standard.string(forKey: "route"), let r = routeFromPage(page) {
|
if let page = UserDefaults.standard.string(forKey: "route"), let r = routeFromPage(page) {
|
||||||
|
|
@ -141,4 +143,16 @@ final class AppModel: ObservableObject {
|
||||||
self?.update = state
|
self?.update = state
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func dismissChangelog() {
|
||||||
|
progress.seenCommit = AppConfig.commit
|
||||||
|
showChangelog = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameBuild(_ a: String?, _ b: String?) -> Bool {
|
||||||
|
guard let a, let b, !a.isEmpty, !b.isEmpty else { return false }
|
||||||
|
if a.caseInsensitiveCompare(b) == .orderedSame { return true }
|
||||||
|
let n = min(a.count, b.count)
|
||||||
|
return n >= 7 && a.prefix(n).caseInsensitiveCompare(b.prefix(n)) == .orderedSame
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ final class Content {
|
||||||
let hw: [J]
|
let hw: [J]
|
||||||
let mluvnice: [J]
|
let mluvnice: [J]
|
||||||
let books: [J]
|
let books: [J]
|
||||||
|
let changelog: [J]
|
||||||
|
|
||||||
init(_ root: [String: Any]) {
|
init(_ root: [String: Any]) {
|
||||||
raw = J(root)
|
raw = J(root)
|
||||||
|
|
@ -63,6 +64,7 @@ final class Content {
|
||||||
hw = raw.arr("hw")
|
hw = raw.arr("hw")
|
||||||
mluvnice = raw.arr("mluvnice")
|
mluvnice = raw.arr("mluvnice")
|
||||||
books = raw.arr("books")
|
books = raw.arr("books")
|
||||||
|
changelog = raw.arr("changelog")
|
||||||
}
|
}
|
||||||
|
|
||||||
func germanUnit(_ id: Int) -> J? { german.first { $0.int("id") == id } }
|
func germanUnit(_ id: Int) -> J? { german.first { $0.int("id") == id } }
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,11 @@ final class ProgressStore {
|
||||||
set { defaults.set(newValue == .en ? "en" : "cs", forKey: "lang") }
|
set { defaults.set(newValue == .en ? "en" : "cs", forKey: "lang") }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var seenCommit: String {
|
||||||
|
get { defaults.string(forKey: "seen_commit") ?? "" }
|
||||||
|
set { defaults.set(newValue, forKey: "seen_commit") }
|
||||||
|
}
|
||||||
|
|
||||||
func germanDone(_ unit: Int, _ ex: Int) -> Bool { defaults.bool(forKey: "g.\(unit).\(ex)") }
|
func germanDone(_ unit: Int, _ ex: Int) -> Bool { defaults.bool(forKey: "g.\(unit).\(ex)") }
|
||||||
func markGerman(_ unit: Int, _ ex: Int) { defaults.set(true, forKey: "g.\(unit).\(ex)") }
|
func markGerman(_ unit: Int, _ ex: Int) { defaults.set(true, forKey: "g.\(unit).\(ex)") }
|
||||||
func vocabDone(_ unit: Int) -> Bool { defaults.bool(forKey: "g.\(unit).vocab") }
|
func vocabDone(_ unit: Int) -> Bool { defaults.bool(forKey: "g.\(unit).vocab") }
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,14 @@ struct PracticeHome: View {
|
||||||
let sum = summarize(vm.content, vm.progress)
|
let sum = summarize(vm.content, vm.progress)
|
||||||
let pct = sum.totalEx == 0 ? 0 : Double(sum.doneEx) / Double(sum.totalEx)
|
let pct = sum.totalEx == 0 ? 0 : Double(sum.doneEx) / Double(sum.totalEx)
|
||||||
List {
|
List {
|
||||||
|
if vm.showChangelog {
|
||||||
|
Section {
|
||||||
|
ChangelogCard(vm: vm)
|
||||||
|
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
|
||||||
|
.listRowBackground(Color.clear)
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
Section {
|
Section {
|
||||||
GlassCard {
|
GlassCard {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
|
@ -59,6 +67,55 @@ struct PracticeHome: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct ChangelogCard: View {
|
||||||
|
@ObservedObject var vm: AppModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let langKey = vm.lang == .en ? "en" : "cs"
|
||||||
|
let blocks = Array(vm.content.changelog.prefix(3))
|
||||||
|
GlassCard {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
HStack {
|
||||||
|
Label(vm.tr("changelog_title"), systemImage: "sparkles")
|
||||||
|
.font(.headline)
|
||||||
|
Spacer()
|
||||||
|
if let date = blocks.first?.strOrNull("date") {
|
||||||
|
Text(date)
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ForEach(Array(blocks.enumerated()), id: \.offset) { index, block in
|
||||||
|
ChangelogLines(index: index, lines: changelogLines(block, langKey))
|
||||||
|
}
|
||||||
|
Button(vm.tr("changelog_dismiss")) { vm.dismissChangelog() }
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func changelogLines(_ block: J, _ langKey: String) -> [String] {
|
||||||
|
let preferred = block.strs(langKey)
|
||||||
|
return preferred.isEmpty ? block.strs("cs") : preferred
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ChangelogLines: View {
|
||||||
|
let index: Int
|
||||||
|
let lines: [String]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
if index > 0 { Spacer().frame(height: 4) }
|
||||||
|
ForEach(Array(lines.enumerated()), id: \.offset) { _, line in
|
||||||
|
Text("• \(line)")
|
||||||
|
.font(.subheadline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct SettingsScreen: View {
|
struct SettingsScreen: View {
|
||||||
@ObservedObject var vm: AppModel
|
@ObservedObject var vm: AppModel
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ mkdir -p "$APPDIR/usr/bin" \
|
||||||
|
|
||||||
cp -f "$ROOT/maturita" "$APPDIR/usr/bin/maturita"
|
cp -f "$ROOT/maturita" "$APPDIR/usr/bin/maturita"
|
||||||
cp -f "$ROOT/data/style.css" "$APPDIR/style.css"
|
cp -f "$ROOT/data/style.css" "$APPDIR/style.css"
|
||||||
|
cp -f "$ROOT/data/changelog.txt" "$APPDIR/changelog.txt"
|
||||||
cp -f "$ICON_512" "$APPDIR/icons/hicolor/512x512/apps/maturita.png"
|
cp -f "$ICON_512" "$APPDIR/icons/hicolor/512x512/apps/maturita.png"
|
||||||
cp -f "$ICON_512" "$APPDIR/usr/share/icons/hicolor/512x512/apps/maturita.png"
|
cp -f "$ICON_512" "$APPDIR/usr/share/icons/hicolor/512x512/apps/maturita.png"
|
||||||
cp -f "$ICON_512" "$APPDIR/maturita.png"
|
cp -f "$ICON_512" "$APPDIR/maturita.png"
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ mkdir -p "$MACOS" "$RES/icons/hicolor/512x512/apps" "$RES/share" "$FW"
|
||||||
|
|
||||||
cp -f "$ROOT/maturita" "$MACOS/maturita-bin"
|
cp -f "$ROOT/maturita" "$MACOS/maturita-bin"
|
||||||
cp -f "$ROOT/data/style.css" "$RES/style.css"
|
cp -f "$ROOT/data/style.css" "$RES/style.css"
|
||||||
|
cp -f "$ROOT/data/changelog.txt" "$RES/changelog.txt"
|
||||||
cp -f "$ICON_512" "$RES/icons/hicolor/512x512/apps/maturita.png"
|
cp -f "$ICON_512" "$RES/icons/hicolor/512x512/apps/maturita.png"
|
||||||
cp -R "$ROOT/data/share/." "$RES/share/"
|
cp -R "$ROOT/data/share/." "$RES/share/"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -690,6 +690,28 @@ def net_tasks(rows) -> list[dict]:
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def parse_changelog(path: Path) -> list[dict]:
|
||||||
|
if not path.is_file():
|
||||||
|
return []
|
||||||
|
entries: list[dict] = []
|
||||||
|
current = None
|
||||||
|
for raw in path.read_text(encoding="utf-8").splitlines():
|
||||||
|
line = raw.strip()
|
||||||
|
if not line or line.startswith("#"):
|
||||||
|
continue
|
||||||
|
if line.startswith("---"):
|
||||||
|
current = {"date": line[3:].strip(), "cs": [], "en": []}
|
||||||
|
entries.append(current)
|
||||||
|
continue
|
||||||
|
if current is None:
|
||||||
|
continue
|
||||||
|
if line.startswith("cs:"):
|
||||||
|
current["cs"].append(line[3:].strip())
|
||||||
|
elif line.startswith("en:"):
|
||||||
|
current["en"].append(line[3:].strip())
|
||||||
|
return [e for e in entries if e["cs"] or e["en"]]
|
||||||
|
|
||||||
|
|
||||||
def parse_i18n(src: str) -> dict:
|
def parse_i18n(src: str) -> dict:
|
||||||
arrays = find_arrays(src)
|
arrays = find_arrays(src)
|
||||||
out = {}
|
out = {}
|
||||||
|
|
@ -1219,6 +1241,7 @@ def main() -> int:
|
||||||
"hw": hw_lessons,
|
"hw": hw_lessons,
|
||||||
"mluvnice": mluvnice,
|
"mluvnice": mluvnice,
|
||||||
"books": books,
|
"books": books,
|
||||||
|
"changelog": parse_changelog(ROOT / "data" / "changelog.txt"),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Fix single-string banks stored as char* "arrays"
|
# Fix single-string banks stored as char* "arrays"
|
||||||
|
|
@ -1252,6 +1275,7 @@ def main() -> int:
|
||||||
print(f" hw lessons: {sum(1 for L in hw_lessons if L['slides'])}")
|
print(f" hw lessons: {sum(1 for L in hw_lessons if L['slides'])}")
|
||||||
print(f" mluvnice: {len(mluvnice)}")
|
print(f" mluvnice: {len(mluvnice)}")
|
||||||
print(f" books quiz items: {[len(b['quiz']) for b in books]}")
|
print(f" books quiz items: {[len(b['quiz']) for b in books]}")
|
||||||
|
print(f" changelog entries: {len(payload.get('changelog', []))}")
|
||||||
missing = []
|
missing = []
|
||||||
for u in german_units:
|
for u in german_units:
|
||||||
if not u["unlocked"]:
|
if not u["unlocked"]:
|
||||||
|
|
|
||||||
234
src/changelog.c
Normal file
234
src/changelog.c
Normal file
|
|
@ -0,0 +1,234 @@
|
||||||
|
#include "maturita.h"
|
||||||
|
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* What's new on the home page after an update */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
/* The packaged notes live in changelog.txt next to style.css. After a
|
||||||
|
* published (or first) start whose commit is not the one the user last
|
||||||
|
* dismissed, the welcome page shows the newest entries. Dismissing stores
|
||||||
|
* APP_COMMIT in settings so the card stays gone until the next update. */
|
||||||
|
|
||||||
|
#define CHANGELOG_MAX_BLOCKS 3
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
char *date;
|
||||||
|
GPtrArray *cs;
|
||||||
|
GPtrArray *en;
|
||||||
|
} ChangelogBlock;
|
||||||
|
|
||||||
|
static struct {
|
||||||
|
GPtrArray *blocks;
|
||||||
|
char *seen;
|
||||||
|
GtkWidget *card;
|
||||||
|
GtkWidget *date;
|
||||||
|
GtkWidget *items;
|
||||||
|
gboolean loaded;
|
||||||
|
} cl;
|
||||||
|
|
||||||
|
static gboolean same_build(const char *a, const char *b) {
|
||||||
|
size_t n;
|
||||||
|
|
||||||
|
if (!a || !b || !a[0] || !b[0])
|
||||||
|
return FALSE;
|
||||||
|
if (g_ascii_strcasecmp(a, b) == 0)
|
||||||
|
return TRUE;
|
||||||
|
n = MIN(strlen(a), strlen(b));
|
||||||
|
return n >= 7 && g_ascii_strncasecmp(a, b, n) == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void changelog_set_seen(const char *sha) {
|
||||||
|
g_free(cl.seen);
|
||||||
|
cl.seen = (sha && sha[0]) ? g_strdup(sha) : NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
const char *changelog_seen(void) {
|
||||||
|
return cl.seen ? cl.seen : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
static void free_block(gpointer data) {
|
||||||
|
ChangelogBlock *b = data;
|
||||||
|
|
||||||
|
if (!b)
|
||||||
|
return;
|
||||||
|
g_free(b->date);
|
||||||
|
if (b->cs)
|
||||||
|
g_ptr_array_free(b->cs, TRUE);
|
||||||
|
if (b->en)
|
||||||
|
g_ptr_array_free(b->en, TRUE);
|
||||||
|
g_free(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
static ChangelogBlock *new_block(const char *date) {
|
||||||
|
ChangelogBlock *b = g_new0(ChangelogBlock, 1);
|
||||||
|
|
||||||
|
b->date = g_strdup(date ? date : "");
|
||||||
|
b->cs = g_ptr_array_new_with_free_func(g_free);
|
||||||
|
b->en = g_ptr_array_new_with_free_func(g_free);
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void load_changelog(void) {
|
||||||
|
gchar *text = NULL;
|
||||||
|
gchar **lines;
|
||||||
|
ChangelogBlock *cur = NULL;
|
||||||
|
guint i;
|
||||||
|
|
||||||
|
if (cl.loaded)
|
||||||
|
return;
|
||||||
|
cl.loaded = TRUE;
|
||||||
|
cl.blocks = g_ptr_array_new_with_free_func(free_block);
|
||||||
|
|
||||||
|
if (!g_file_get_contents(CHANGELOG_FILE, &text, NULL, NULL) || !text)
|
||||||
|
return;
|
||||||
|
|
||||||
|
lines = g_strsplit(text, "\n", -1);
|
||||||
|
g_free(text);
|
||||||
|
|
||||||
|
for (i = 0; lines[i]; i++) {
|
||||||
|
char *line = g_strstrip(lines[i]);
|
||||||
|
|
||||||
|
if (!line[0] || line[0] == '#')
|
||||||
|
continue;
|
||||||
|
if (g_str_has_prefix(line, "---")) {
|
||||||
|
char *date = g_strstrip(line + 3);
|
||||||
|
|
||||||
|
cur = new_block(date);
|
||||||
|
g_ptr_array_add(cl.blocks, cur);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!cur)
|
||||||
|
continue;
|
||||||
|
if (g_str_has_prefix(line, "cs:"))
|
||||||
|
g_ptr_array_add(cur->cs, g_strdup(g_strstrip(line + 3)));
|
||||||
|
else if (g_str_has_prefix(line, "en:"))
|
||||||
|
g_ptr_array_add(cur->en, g_strdup(g_strstrip(line + 3)));
|
||||||
|
}
|
||||||
|
g_strfreev(lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
static gboolean changelog_pending(void) {
|
||||||
|
load_changelog();
|
||||||
|
if (!cl.blocks || cl.blocks->len == 0)
|
||||||
|
return FALSE;
|
||||||
|
return !same_build(APP_COMMIT, cl.seen);
|
||||||
|
}
|
||||||
|
|
||||||
|
static GPtrArray *block_lines(const ChangelogBlock *b) {
|
||||||
|
GPtrArray *src;
|
||||||
|
|
||||||
|
if (!b)
|
||||||
|
return NULL;
|
||||||
|
src = (app_lang == LANG_EN) ? b->en : b->cs;
|
||||||
|
if (src && src->len > 0)
|
||||||
|
return src;
|
||||||
|
return (b->cs && b->cs->len > 0) ? b->cs : b->en;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void fill_items(void) {
|
||||||
|
GtkWidget *child;
|
||||||
|
guint i, shown = 0;
|
||||||
|
|
||||||
|
if (!cl.items)
|
||||||
|
return;
|
||||||
|
|
||||||
|
while ((child = gtk_widget_get_first_child(cl.items)))
|
||||||
|
gtk_box_remove(GTK_BOX(cl.items), child);
|
||||||
|
|
||||||
|
if (!cl.blocks)
|
||||||
|
return;
|
||||||
|
|
||||||
|
for (i = 0; i < cl.blocks->len && shown < CHANGELOG_MAX_BLOCKS; i++) {
|
||||||
|
const ChangelogBlock *b = g_ptr_array_index(cl.blocks, i);
|
||||||
|
GPtrArray *lines = block_lines(b);
|
||||||
|
guint j;
|
||||||
|
|
||||||
|
if (!lines || lines->len == 0)
|
||||||
|
continue;
|
||||||
|
if (shown == 0 && cl.date)
|
||||||
|
gtk_label_set_text(GTK_LABEL(cl.date), b->date ? b->date : "");
|
||||||
|
if (shown > 0) {
|
||||||
|
GtkWidget *gap = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
|
||||||
|
|
||||||
|
gtk_widget_set_margin_top(gap, 8);
|
||||||
|
gtk_box_append(GTK_BOX(cl.items), gap);
|
||||||
|
}
|
||||||
|
for (j = 0; j < lines->len; j++) {
|
||||||
|
char *text = g_strdup_printf("• %s",
|
||||||
|
(const char *)lines->pdata[j]);
|
||||||
|
GtkWidget *row = gtk_label_new(text);
|
||||||
|
|
||||||
|
gtk_widget_set_halign(row, GTK_ALIGN_START);
|
||||||
|
gtk_label_set_wrap(GTK_LABEL(row), TRUE);
|
||||||
|
gtk_label_set_xalign(GTK_LABEL(row), 0.0);
|
||||||
|
gtk_label_set_max_width_chars(GTK_LABEL(row), 52);
|
||||||
|
gtk_widget_add_css_class(row, "changelog-item");
|
||||||
|
gtk_box_append(GTK_BOX(cl.items), row);
|
||||||
|
g_free(text);
|
||||||
|
}
|
||||||
|
shown++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void changelog_apply_lang(void) {
|
||||||
|
if (cl.card && gtk_widget_get_visible(cl.card))
|
||||||
|
fill_items();
|
||||||
|
}
|
||||||
|
|
||||||
|
static void on_changelog_dismiss(GtkButton *btn, gpointer data) {
|
||||||
|
(void)btn;
|
||||||
|
(void)data;
|
||||||
|
changelog_set_seen(APP_COMMIT);
|
||||||
|
save_settings();
|
||||||
|
if (cl.card)
|
||||||
|
gtk_widget_set_visible(cl.card, FALSE);
|
||||||
|
}
|
||||||
|
|
||||||
|
GtkWidget *build_changelog_card(void) {
|
||||||
|
GtkWidget *card;
|
||||||
|
GtkWidget *head;
|
||||||
|
GtkWidget *title;
|
||||||
|
GtkWidget *date;
|
||||||
|
GtkWidget *items;
|
||||||
|
GtkWidget *dismiss;
|
||||||
|
|
||||||
|
load_changelog();
|
||||||
|
|
||||||
|
card = gtk_box_new(GTK_ORIENTATION_VERTICAL, 10);
|
||||||
|
gtk_widget_add_css_class(card, "changelog-card");
|
||||||
|
gtk_widget_set_hexpand(card, TRUE);
|
||||||
|
gtk_widget_set_visible(card, changelog_pending());
|
||||||
|
|
||||||
|
head = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 12);
|
||||||
|
gtk_box_append(GTK_BOX(card), head);
|
||||||
|
|
||||||
|
title = gtk_label_new(NULL);
|
||||||
|
i18n_bind(title, "changelog_title", 0);
|
||||||
|
gtk_widget_set_halign(title, GTK_ALIGN_START);
|
||||||
|
gtk_widget_set_hexpand(title, TRUE);
|
||||||
|
gtk_widget_add_css_class(title, "changelog-title");
|
||||||
|
gtk_box_append(GTK_BOX(head), title);
|
||||||
|
|
||||||
|
date = gtk_label_new("");
|
||||||
|
gtk_widget_set_valign(date, GTK_ALIGN_CENTER);
|
||||||
|
gtk_widget_add_css_class(date, "changelog-date");
|
||||||
|
gtk_box_append(GTK_BOX(head), date);
|
||||||
|
|
||||||
|
items = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6);
|
||||||
|
gtk_box_append(GTK_BOX(card), items);
|
||||||
|
|
||||||
|
dismiss = gtk_button_new();
|
||||||
|
i18n_bind(dismiss, "changelog_dismiss", 1);
|
||||||
|
gtk_widget_set_halign(dismiss, GTK_ALIGN_START);
|
||||||
|
gtk_widget_add_css_class(dismiss, "changelog-dismiss");
|
||||||
|
g_signal_connect(dismiss, "clicked", G_CALLBACK(on_changelog_dismiss), NULL);
|
||||||
|
gtk_box_append(GTK_BOX(card), dismiss);
|
||||||
|
|
||||||
|
cl.card = card;
|
||||||
|
cl.date = date;
|
||||||
|
cl.items = items;
|
||||||
|
fill_items();
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
@ -78,6 +78,8 @@ const TrEntry tr_ui[] = {
|
||||||
{"update_err_badapk",
|
{"update_err_badapk",
|
||||||
"Stažený soubor není platný build maturita.c.",
|
"Stažený soubor není platný build maturita.c.",
|
||||||
"The downloaded file is not a valid maturita.c build."},
|
"The downloaded file is not a valid maturita.c build."},
|
||||||
|
{"changelog_title", "Co je nového", "What's new"},
|
||||||
|
{"changelog_dismiss", "Rozumím", "Got it"},
|
||||||
{"welcome_feat1_title", "Efektivní procvičování", "Focused practice"},
|
{"welcome_feat1_title", "Efektivní procvičování", "Focused practice"},
|
||||||
{"welcome_feat1_body",
|
{"welcome_feat1_body",
|
||||||
"Cvičení, nápovědy a vokabeltraining. Aplikace si pamatuje, kde jste skončili.",
|
"Cvičení, nápovědy a vokabeltraining. Aplikace si pamatuje, kde jste skončili.",
|
||||||
|
|
@ -1557,6 +1559,7 @@ void apply_language(void) {
|
||||||
refresh_welcome_heading();
|
refresh_welcome_heading();
|
||||||
refresh_stats_ui();
|
refresh_stats_ui();
|
||||||
update_apply_lang();
|
update_apply_lang();
|
||||||
|
changelog_apply_lang();
|
||||||
net_lessons_apply_lang();
|
net_lessons_apply_lang();
|
||||||
hw_lessons_apply_lang();
|
hw_lessons_apply_lang();
|
||||||
search_apply_lang();
|
search_apply_lang();
|
||||||
|
|
|
||||||
|
|
@ -414,6 +414,8 @@ static gboolean run_smoke_test(gpointer data) {
|
||||||
fprintf(f, "commit=%s\n", APP_COMMIT);
|
fprintf(f, "commit=%s\n", APP_COMMIT);
|
||||||
fprintf(f, "css=%s\n",
|
fprintf(f, "css=%s\n",
|
||||||
g_file_test(CSS_FILE, G_FILE_TEST_IS_REGULAR) ? "ok" : "MISSING");
|
g_file_test(CSS_FILE, G_FILE_TEST_IS_REGULAR) ? "ok" : "MISSING");
|
||||||
|
fprintf(f, "changelog=%s\n",
|
||||||
|
g_file_test(CHANGELOG_FILE, G_FILE_TEST_IS_REGULAR) ? "ok" : "MISSING");
|
||||||
fprintf(f, "progress_dir=%s\n", app_progress_dir ? app_progress_dir : "?");
|
fprintf(f, "progress_dir=%s\n", app_progress_dir ? app_progress_dir : "?");
|
||||||
|
|
||||||
for (i = 0; pages[i]; i++) {
|
for (i = 0; pages[i]; i++) {
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
#define HW_SUBJ 2 /* index of "Technické vybavení" */
|
#define HW_SUBJ 2 /* index of "Technické vybavení" */
|
||||||
#define CZ_SUBJ 3 /* index of "Český jazyk a literatura" */
|
#define CZ_SUBJ 3 /* index of "Český jazyk a literatura" */
|
||||||
#define CSS_FILE "style.css"
|
#define CSS_FILE "style.css"
|
||||||
|
#define CHANGELOG_FILE "changelog.txt"
|
||||||
#define APP_ID "org.maturita.Maturita"
|
#define APP_ID "org.maturita.Maturita"
|
||||||
/* Published builds bake in the commit they were made from (see COMMIT in the
|
/* Published builds bake in the commit they were made from (see COMMIT in the
|
||||||
* Makefile), which is what the updater compares against the build published
|
* Makefile), which is what the updater compares against the build published
|
||||||
|
|
@ -907,6 +908,11 @@ void update_apply_lang(void);
|
||||||
void update_on_settings_open(void);
|
void update_on_settings_open(void);
|
||||||
GtkWidget *build_update_banner(void);
|
GtkWidget *build_update_banner(void);
|
||||||
GtkWidget *build_update_box(void);
|
GtkWidget *build_update_box(void);
|
||||||
|
/* changelog.c */
|
||||||
|
void changelog_set_seen(const char *sha);
|
||||||
|
const char *changelog_seen(void);
|
||||||
|
void changelog_apply_lang(void);
|
||||||
|
GtkWidget *build_changelog_card(void);
|
||||||
void draw_finish_cell(GtkDrawingArea *area, cairo_t *cr,
|
void draw_finish_cell(GtkDrawingArea *area, cairo_t *cr,
|
||||||
int width, int height, gpointer data);
|
int width, int height, gpointer data);
|
||||||
void road_point(double t, double *ox, double *oy);
|
void road_point(double t, double *ox, double *oy);
|
||||||
|
|
|
||||||
|
|
@ -240,6 +240,13 @@ GtkWidget *build_welcome_page(void) {
|
||||||
|
|
||||||
gtk_box_append(GTK_BOX(hero), welcome_stat());
|
gtk_box_append(GTK_BOX(hero), welcome_stat());
|
||||||
|
|
||||||
|
{
|
||||||
|
GtkWidget *notes = build_changelog_card();
|
||||||
|
|
||||||
|
gtk_widget_set_margin_top(notes, 28);
|
||||||
|
gtk_box_append(GTK_BOX(page), notes);
|
||||||
|
}
|
||||||
|
|
||||||
features = gtk_box_new(GTK_ORIENTATION_VERTICAL, 14);
|
features = gtk_box_new(GTK_ORIENTATION_VERTICAL, 14);
|
||||||
gtk_widget_set_margin_top(features, 36);
|
gtk_widget_set_margin_top(features, 36);
|
||||||
gtk_box_append(GTK_BOX(page), features);
|
gtk_box_append(GTK_BOX(page), features);
|
||||||
|
|
|
||||||
|
|
@ -287,6 +287,7 @@ void save_settings(void) {
|
||||||
app_color_mode == MODE_LIGHT ? "light" : "dark");
|
app_color_mode == MODE_LIGHT ? "light" : "dark");
|
||||||
g_key_file_set_string(kf, "ui", "lang",
|
g_key_file_set_string(kf, "ui", "lang",
|
||||||
app_lang == LANG_EN ? "en" : "cs");
|
app_lang == LANG_EN ? "en" : "cs");
|
||||||
|
g_key_file_set_string(kf, "ui", "seen_commit", changelog_seen());
|
||||||
|
|
||||||
if (g_mkdir_with_parents(PROGRESS_DIR, 0755) != 0) {
|
if (g_mkdir_with_parents(PROGRESS_DIR, 0755) != 0) {
|
||||||
g_key_file_free(kf);
|
g_key_file_free(kf);
|
||||||
|
|
@ -347,6 +348,12 @@ void load_settings(void) {
|
||||||
g_free(lang);
|
g_free(lang);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
gchar *seen = g_key_file_get_string(kf, "ui", "seen_commit", NULL);
|
||||||
|
|
||||||
|
changelog_set_seen(seen);
|
||||||
|
g_free(seen);
|
||||||
|
}
|
||||||
|
|
||||||
g_key_file_free(kf);
|
g_key_file_free(kf);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue