From 2cf8b7123023276421f9538e880435bb624418a0 Mon Sep 17 00:00:00 2001 From: Rombond Date: Thu, 12 Nov 2026 10:27:10 -0500 Subject: [PATCH 1/8] iOS stats: singular and plural counts, most-watched captions on two lines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "1 plays" / "1 lectures" under a title watched once, and the same for the other counters of the Statistics page (movies and shows, available and declined requests, titles per decade, never watched, and N more). New mobile strings m.stats.* carry one and other forms; the web keys stay as they are (Android reads them directly). Poster captions ("6,9 h · 8 épisodes regardés") wrap to two lines instead of being cut, with the space kept so the cards line up. The iOS catalog is regenerated (it also picks up strings that were already in locales/). --- .../app/src/main/res/values-fr/strings.xml | 9 + android/app/src/main/res/values/strings.xml | 9 + design/generated/Localizable.xcstrings | 153 +++++++++++++ .../generated/android/values-fr/strings.xml | 9 + design/generated/android/values/strings.xml | 9 + design/generated/strings.json | 18 ++ .../Features/Admin/PosterCarousel.swift | 9 +- .../Tipsarr/Features/Admin/StatsView.swift | 11 +- ios/Tipsarr/Tipsarr/Localizable.xcstrings | 202 ++++++++++++++++++ locales/en/mobile.json | 9 + locales/fr/mobile.json | 9 + 11 files changed, 439 insertions(+), 8 deletions(-) diff --git a/android/app/src/main/res/values-fr/strings.xml b/android/app/src/main/res/values-fr/strings.xml index 29e33082..fee2366c 100644 --- a/android/app/src/main/res/values-fr/strings.xml +++ b/android/app/src/main/res/values-fr/strings.xml @@ -470,7 +470,16 @@ Système Suit ton téléphone Réglages + %1$s disponible | %1$s disponibles + %1$s refusée | %1$s refusées + %1$s épisode regardé | %1$s épisodes regardés + %1$s film | %1$s films + %1$s lecture | %1$s lectures + %1$s série | %1$s séries + %1$s titre | %1$s titres + %1$s titre de la bibliothèque que personne n’a jamais lu, les plus anciens d’abord (admins uniquement). | %1$s titres de la bibliothèque que personne n’a jamais lus, les plus anciens d’abord (admins uniquement). Statistiques + et %1$s autre | et %1$s autres %1$s films et %2$s séries connus dans la bibliothèque. Toutes les %s Dernier lancement %s diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 70bd718f..75a2a335 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -470,7 +470,16 @@ System Follows your phone Settings + %1$s available | %1$s available + %1$s declined | %1$s declined + %1$s episode watched | %1$s episodes watched + %1$s movie | %1$s movies + %1$s play | %1$s plays + %1$s TV show | %1$s TV shows + %1$s title | %1$s titles + %1$s title in the library that nobody ever played, oldest first (admins only). | %1$s titles in the library that nobody ever played, oldest first (admins only). Statistics + and %1$s more | and %1$s more %1$s movies and %2$s TV shows known in the library. Runs every %s Last run %s diff --git a/design/generated/Localizable.xcstrings b/design/generated/Localizable.xcstrings index a6fe309c..b06994b9 100644 --- a/design/generated/Localizable.xcstrings +++ b/design/generated/Localizable.xcstrings @@ -7534,6 +7534,142 @@ } } }, + "m.stats.n_available": { + "comment": "placeholders: count, count", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$@ available | %1$@ available" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%1$@ disponible | %1$@ disponibles" + } + } + } + }, + "m.stats.n_declined": { + "comment": "placeholders: count, count", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$@ declined | %1$@ declined" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%1$@ refusée | %1$@ refusées" + } + } + } + }, + "m.stats.n_episodes": { + "comment": "placeholders: count, count", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$@ episode watched | %1$@ episodes watched" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%1$@ épisode regardé | %1$@ épisodes regardés" + } + } + } + }, + "m.stats.n_movies": { + "comment": "placeholders: count, count", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$@ movie | %1$@ movies" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%1$@ film | %1$@ films" + } + } + } + }, + "m.stats.n_plays": { + "comment": "placeholders: count, count", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$@ play | %1$@ plays" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%1$@ lecture | %1$@ lectures" + } + } + } + }, + "m.stats.n_shows": { + "comment": "placeholders: count, count", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$@ TV show | %1$@ TV shows" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%1$@ série | %1$@ séries" + } + } + } + }, + "m.stats.n_titles": { + "comment": "placeholders: count, count", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$@ title | %1$@ titles" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%1$@ titre | %1$@ titres" + } + } + } + }, + "m.stats.never_hint": { + "comment": "placeholders: count, count", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$@ title in the library that nobody ever played, oldest first (admins only). | %1$@ titles in the library that nobody ever played, oldest first (admins only)." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%1$@ titre de la bibliothèque que personne n’a jamais lu, les plus anciens d’abord (admins uniquement). | %1$@ titres de la bibliothèque que personne n’a jamais lus, les plus anciens d’abord (admins uniquement)." + } + } + } + }, "m.stats.open": { "localizations": { "en": { @@ -7550,6 +7686,23 @@ } } }, + "m.stats.unwatched_more": { + "comment": "placeholders: count, count", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "and %1$@ more | and %1$@ more" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "et %1$@ autre | et %1$@ autres" + } + } + } + }, "m.sync.counts": { "localizations": { "en": { diff --git a/design/generated/android/values-fr/strings.xml b/design/generated/android/values-fr/strings.xml index 29e33082..fee2366c 100644 --- a/design/generated/android/values-fr/strings.xml +++ b/design/generated/android/values-fr/strings.xml @@ -470,7 +470,16 @@ Système Suit ton téléphone Réglages + %1$s disponible | %1$s disponibles + %1$s refusée | %1$s refusées + %1$s épisode regardé | %1$s épisodes regardés + %1$s film | %1$s films + %1$s lecture | %1$s lectures + %1$s série | %1$s séries + %1$s titre | %1$s titres + %1$s titre de la bibliothèque que personne n’a jamais lu, les plus anciens d’abord (admins uniquement). | %1$s titres de la bibliothèque que personne n’a jamais lus, les plus anciens d’abord (admins uniquement). Statistiques + et %1$s autre | et %1$s autres %1$s films et %2$s séries connus dans la bibliothèque. Toutes les %s Dernier lancement %s diff --git a/design/generated/android/values/strings.xml b/design/generated/android/values/strings.xml index 70bd718f..75a2a335 100644 --- a/design/generated/android/values/strings.xml +++ b/design/generated/android/values/strings.xml @@ -470,7 +470,16 @@ System Follows your phone Settings + %1$s available | %1$s available + %1$s declined | %1$s declined + %1$s episode watched | %1$s episodes watched + %1$s movie | %1$s movies + %1$s play | %1$s plays + %1$s TV show | %1$s TV shows + %1$s title | %1$s titles + %1$s title in the library that nobody ever played, oldest first (admins only). | %1$s titles in the library that nobody ever played, oldest first (admins only). Statistics + and %1$s more | and %1$s more %1$s movies and %2$s TV shows known in the library. Runs every %s Last run %s diff --git a/design/generated/strings.json b/design/generated/strings.json index a80abe3c..ed0f7af5 100644 --- a/design/generated/strings.json +++ b/design/generated/strings.json @@ -865,6 +865,15 @@ "m.accounts.title_one": "Account", "m.settings.hidden": "Hidden content", "m.stats.open": "Statistics", + "m.stats.n_plays": "{count} play | {count} plays", + "m.stats.n_episodes": "{count} episode watched | {count} episodes watched", + "m.stats.n_titles": "{count} title | {count} titles", + "m.stats.n_movies": "{count} movie | {count} movies", + "m.stats.n_shows": "{count} TV show | {count} TV shows", + "m.stats.n_available": "{count} available | {count} available", + "m.stats.n_declined": "{count} declined | {count} declined", + "m.stats.unwatched_more": "and {count} more | and {count} more", + "m.stats.never_hint": "{count} title in the library that nobody ever played, oldest first (admins only). | {count} titles in the library that nobody ever played, oldest first (admins only).", "m.library.under": "under {duration}", "m.library.close": "Close", "m.library.genre_all": "All selected", @@ -1882,6 +1891,15 @@ "m.accounts.title_one": "Compte", "m.settings.hidden": "Contenu masqué", "m.stats.open": "Statistiques", + "m.stats.n_plays": "{count} lecture | {count} lectures", + "m.stats.n_episodes": "{count} épisode regardé | {count} épisodes regardés", + "m.stats.n_titles": "{count} titre | {count} titres", + "m.stats.n_movies": "{count} film | {count} films", + "m.stats.n_shows": "{count} série | {count} séries", + "m.stats.n_available": "{count} disponible | {count} disponibles", + "m.stats.n_declined": "{count} refusée | {count} refusées", + "m.stats.unwatched_more": "et {count} autre | et {count} autres", + "m.stats.never_hint": "{count} titre de la bibliothèque que personne n’a jamais lu, les plus anciens d’abord (admins uniquement). | {count} titres de la bibliothèque que personne n’a jamais lus, les plus anciens d’abord (admins uniquement).", "m.library.under": "moins de {duration}", "m.library.close": "Fermer", "m.library.genre_all": "Tous les genres", diff --git a/ios/Tipsarr/Tipsarr/Features/Admin/PosterCarousel.swift b/ios/Tipsarr/Tipsarr/Features/Admin/PosterCarousel.swift index 72d0164b..790e63c4 100644 --- a/ios/Tipsarr/Tipsarr/Features/Admin/PosterCarousel.swift +++ b/ios/Tipsarr/Tipsarr/Features/Admin/PosterCarousel.swift @@ -60,7 +60,10 @@ struct PosterCarousel: View { .background(Tokens.palette.muted) .clipShape(.rect(cornerRadius: Tokens.Radius.md)) Text(verbatim: row.title).font(.subheadline.weight(.medium)).lineLimit(1) - Text(verbatim: row.note).font(.caption).foregroundStyle(Tokens.palette.mutedFg).lineLimit(1) + // Two lines: "6,9 h · 8 épisodes regardés" does not fit a poster's width on one. Space for both is kept so the cards line up. + Text(verbatim: row.note).font(.caption).foregroundStyle(Tokens.palette.mutedFg) + .lineLimit(2, reservesSpace: true) + .fixedSize(horizontal: false, vertical: true) } .frame(width: posterWidth) .accessibilityElement(children: .ignore) @@ -124,9 +127,9 @@ enum StatsFormat { return value >= 100 ? "\(Int(value.rounded())) \(unit)" : "\(value.formatted(.number.precision(.fractionLength(1)))) \(unit)" } - /// "12.5 h · 8 episodes watched" for shows, "3.2 h · 2 plays" for movies. + /// "12.5 h · 8 episodes watched" for shows, "3.2 h · 1 play" for movies. static func note(_ item: StatsTop) -> String { - let count = item.type == .tv ? L10n.string("stats.n_episodes", String(item.plays)) : L10n.string("stats.n_plays", String(item.plays)) + let count = Plural.text(item.type == .tv ? "m.stats.n_episodes" : "m.stats.n_plays", count: item.plays) return "\(hours(item.hours)) · \(count)" } } diff --git a/ios/Tipsarr/Tipsarr/Features/Admin/StatsView.swift b/ios/Tipsarr/Tipsarr/Features/Admin/StatsView.swift index 97e9f45f..5682418a 100644 --- a/ios/Tipsarr/Tipsarr/Features/Admin/StatsView.swift +++ b/ios/Tipsarr/Tipsarr/Features/Admin/StatsView.swift @@ -114,11 +114,12 @@ struct StatsView: View { Grid(horizontalSpacing: Tokens.Spacing.md, verticalSpacing: Tokens.Spacing.md) { GridRow { tile(r.exact ? "stats.tile_hours" : "stats.tile_hours_est", StatsFormat.hours(r.hours), L10n.string("stats.unit_hours_long")) - tile("stats.tile_titles", String(r.titles), L10n.string("stats.tile_titles_sub", String(r.movies), String(r.shows))) + tile("stats.tile_titles", String(r.titles), "\(Plural.text("m.stats.n_movies", count: r.movies)) · \(Plural.text("m.stats.n_shows", count: r.shows))") } GridRow { tile("stats.tile_plays", String(r.plays), r.genres.first.map { L10n.string("stats.tile_plays_sub", $0.name) }) - tile("stats.tile_requests", String(r.requestsMade), L10n.string("stats.tile_requests_sub", String(r.requestsAvailable), String(r.requestsDeclined))) + tile("stats.tile_requests", String(r.requestsMade), + "\(Plural.text("m.stats.n_available", count: r.requestsAvailable)) · \(Plural.text("m.stats.n_declined", count: r.requestsDeclined))") } } } @@ -155,7 +156,7 @@ struct StatsView: View { BarMark(x: .value("v", byHours && item.hours > 0 ? item.hours : Double(item.titles)), y: .value("Name", item.name)) .foregroundStyle(Tokens.palette.primary) .annotation(position: .trailing) { - Text(verbatim: byHours && item.hours > 0 ? StatsFormat.hours(item.hours) : L10n.string("stats.n_titles", String(item.titles))) + Text(verbatim: byHours && item.hours > 0 ? StatsFormat.hours(item.hours) : Plural.text("m.stats.n_titles", count: item.titles)) .font(.caption2).foregroundStyle(Tokens.palette.mutedFg) } } @@ -203,7 +204,7 @@ struct StatsView: View { Text(who == nil || !isAdmin ? "stats.unwatched_mine" : "stats.unwatched_others") .font(.footnote).foregroundStyle(Tokens.palette.mutedFg) if unwatched.total > unwatched.items.count { - Text(verbatim: L10n.string("stats.unwatched_more", String(unwatched.total - unwatched.items.count))) + Text(verbatim: Plural.text("m.stats.unwatched_more", count: unwatched.total - unwatched.items.count)) .font(.footnote).foregroundStyle(Tokens.palette.mutedFg) } } @@ -220,7 +221,7 @@ struct StatsView: View { Text("stats.never_none").font(.subheadline).foregroundStyle(Tokens.palette.mutedFg) } else { PosterCarousel(title: "stats.never_title", titleKey: "stats.never_title", rows: neverWatched.items.map(\.row)) - Text(verbatim: L10n.string("stats.never_hint", String(neverWatched.total))).font(.footnote).foregroundStyle(Tokens.palette.mutedFg) + Text(verbatim: Plural.text("m.stats.never_hint", count: neverWatched.total)).font(.footnote).foregroundStyle(Tokens.palette.mutedFg) } } } diff --git a/ios/Tipsarr/Tipsarr/Localizable.xcstrings b/ios/Tipsarr/Tipsarr/Localizable.xcstrings index a52de2e6..b06994b9 100644 --- a/ios/Tipsarr/Tipsarr/Localizable.xcstrings +++ b/ios/Tipsarr/Tipsarr/Localizable.xcstrings @@ -1574,6 +1574,22 @@ } } }, + "error.address_refused": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This address is not allowed (loopback or link-local)." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Cette adresse n’est pas autorisée (loopback ou link-local)." + } + } + } + }, "error.admin_only": { "localizations": { "en": { @@ -4133,6 +4149,23 @@ } } }, + "login.demo_apps": { + "comment": "placeholders: server, admin, user, password", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Using the mobile apps? Server %1$@, user %2$@ (admin) or %3$@, password %4$@." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Tu utilises les applis mobiles ? Serveur : %1$@, identifiant %2$@ (admin) ou %3$@, mot de passe %4$@." + } + } + } + }, "login.demo_desc": { "localizations": { "en": { @@ -7501,6 +7534,142 @@ } } }, + "m.stats.n_available": { + "comment": "placeholders: count, count", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$@ available | %1$@ available" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%1$@ disponible | %1$@ disponibles" + } + } + } + }, + "m.stats.n_declined": { + "comment": "placeholders: count, count", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$@ declined | %1$@ declined" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%1$@ refusée | %1$@ refusées" + } + } + } + }, + "m.stats.n_episodes": { + "comment": "placeholders: count, count", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$@ episode watched | %1$@ episodes watched" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%1$@ épisode regardé | %1$@ épisodes regardés" + } + } + } + }, + "m.stats.n_movies": { + "comment": "placeholders: count, count", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$@ movie | %1$@ movies" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%1$@ film | %1$@ films" + } + } + } + }, + "m.stats.n_plays": { + "comment": "placeholders: count, count", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$@ play | %1$@ plays" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%1$@ lecture | %1$@ lectures" + } + } + } + }, + "m.stats.n_shows": { + "comment": "placeholders: count, count", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$@ TV show | %1$@ TV shows" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%1$@ série | %1$@ séries" + } + } + } + }, + "m.stats.n_titles": { + "comment": "placeholders: count, count", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$@ title | %1$@ titles" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%1$@ titre | %1$@ titres" + } + } + } + }, + "m.stats.never_hint": { + "comment": "placeholders: count, count", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$@ title in the library that nobody ever played, oldest first (admins only). | %1$@ titles in the library that nobody ever played, oldest first (admins only)." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%1$@ titre de la bibliothèque que personne n’a jamais lu, les plus anciens d’abord (admins uniquement). | %1$@ titres de la bibliothèque que personne n’a jamais lus, les plus anciens d’abord (admins uniquement)." + } + } + } + }, "m.stats.open": { "localizations": { "en": { @@ -7517,6 +7686,23 @@ } } }, + "m.stats.unwatched_more": { + "comment": "placeholders: count, count", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "and %1$@ more | and %1$@ more" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "et %1$@ autre | et %1$@ autres" + } + } + } + }, "m.sync.counts": { "localizations": { "en": { @@ -10197,6 +10383,22 @@ } } }, + "profile.picture_too_big": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "That picture is too large: at most 4096 × 4096 pixels." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Cette image est trop grande : 4096 × 4096 pixels au maximum." + } + } + } + }, "profile.picture_too_large": { "localizations": { "en": { diff --git a/locales/en/mobile.json b/locales/en/mobile.json index 8987f190..cfbdbea3 100644 --- a/locales/en/mobile.json +++ b/locales/en/mobile.json @@ -185,6 +185,15 @@ "m.accounts.title_one": "Account", "m.settings.hidden": "Hidden content", "m.stats.open": "Statistics", + "m.stats.n_plays": "{count} play | {count} plays", + "m.stats.n_episodes": "{count} episode watched | {count} episodes watched", + "m.stats.n_titles": "{count} title | {count} titles", + "m.stats.n_movies": "{count} movie | {count} movies", + "m.stats.n_shows": "{count} TV show | {count} TV shows", + "m.stats.n_available": "{count} available | {count} available", + "m.stats.n_declined": "{count} declined | {count} declined", + "m.stats.unwatched_more": "and {count} more | and {count} more", + "m.stats.never_hint": "{count} title in the library that nobody ever played, oldest first (admins only). | {count} titles in the library that nobody ever played, oldest first (admins only).", "m.library.under": "under {duration}", "m.library.close": "Close", "m.library.genre_all": "All selected", diff --git a/locales/fr/mobile.json b/locales/fr/mobile.json index 8dd74d31..6bdf3b38 100644 --- a/locales/fr/mobile.json +++ b/locales/fr/mobile.json @@ -185,6 +185,15 @@ "m.accounts.title_one": "Compte", "m.settings.hidden": "Contenu masqué", "m.stats.open": "Statistiques", + "m.stats.n_plays": "{count} lecture | {count} lectures", + "m.stats.n_episodes": "{count} épisode regardé | {count} épisodes regardés", + "m.stats.n_titles": "{count} titre | {count} titres", + "m.stats.n_movies": "{count} film | {count} films", + "m.stats.n_shows": "{count} série | {count} séries", + "m.stats.n_available": "{count} disponible | {count} disponibles", + "m.stats.n_declined": "{count} refusée | {count} refusées", + "m.stats.unwatched_more": "et {count} autre | et {count} autres", + "m.stats.never_hint": "{count} titre de la bibliothèque que personne n’a jamais lu, les plus anciens d’abord (admins uniquement). | {count} titres de la bibliothèque que personne n’a jamais lus, les plus anciens d’abord (admins uniquement).", "m.library.under": "moins de {duration}", "m.library.close": "Fermer", "m.library.genre_all": "Tous les genres", From 517dba17a7c5b1321866d013440bf9ca3f44df6d Mon Sep 17 00:00:00 2001 From: Rombond Date: Thu, 12 Nov 2026 10:27:10 -0500 Subject: [PATCH 2/8] iOS: translate the TMDB department of people Search cards and the person screen showed TMDB's known_for_department as sent ("Acting"). It now goes through the dept.* strings the web app already uses, falling back to TMDB's text for an unknown department. --- ios/Tipsarr/Tipsarr/Core/Support/Plural.swift | 8 ++++++++ ios/Tipsarr/Tipsarr/Features/Person/PersonScreen.swift | 2 +- ios/Tipsarr/Tipsarr/Features/Search/SearchView.swift | 4 ++-- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/ios/Tipsarr/Tipsarr/Core/Support/Plural.swift b/ios/Tipsarr/Tipsarr/Core/Support/Plural.swift index 5095f498..be2fc9f8 100644 --- a/ios/Tipsarr/Tipsarr/Core/Support/Plural.swift +++ b/ios/Tipsarr/Tipsarr/Core/Support/Plural.swift @@ -33,6 +33,14 @@ enum L10n { return String(format: format, arguments: args.map { $0 as NSString }) } + /// TMDB department names ("Acting", "Directing") are English whatever the request language: the translated + /// name for the ones the catalog knows (`dept.`, shared with the web app), else TMDB's text. + static func department(_ raw: String) -> String { + let key = "dept.\(raw)" + let text = AppLanguage.bundle.localizedString(forKey: key, value: nil, table: nil) + return text == key ? raw : text + } + /// Localized string for a dynamic key with `%@` / `%1$@` arguments (SwiftUI cannot do this with a runtime key). /// Arguments are turned into text first: a number passed as a C vararg to `%@` crashes. static func string(_ key: String, _ args: CustomStringConvertible...) -> String { diff --git a/ios/Tipsarr/Tipsarr/Features/Person/PersonScreen.swift b/ios/Tipsarr/Tipsarr/Features/Person/PersonScreen.swift index 561f5829..2c95dcbc 100644 --- a/ios/Tipsarr/Tipsarr/Features/Person/PersonScreen.swift +++ b/ios/Tipsarr/Tipsarr/Features/Person/PersonScreen.swift @@ -41,7 +41,7 @@ struct PersonScreen: View { VStack(alignment: .leading, spacing: Tokens.Spacing.xs) { Text(verbatim: person.name).font(.title2.weight(.bold)).accessibilityAddTraits(.isHeader) if let department = person.department, !department.isEmpty { - Text(verbatim: department).font(.subheadline).foregroundStyle(Tokens.palette.mutedFg) + Text(verbatim: L10n.department(department)).font(.subheadline).foregroundStyle(Tokens.palette.mutedFg) } if let born = person.birthday.flatMap(Self.date) { Text(verbatim: L10n.string("m.person.born", born)).font(.footnote).foregroundStyle(Tokens.palette.mutedFg) diff --git a/ios/Tipsarr/Tipsarr/Features/Search/SearchView.swift b/ios/Tipsarr/Tipsarr/Features/Search/SearchView.swift index 61509237..205e3685 100644 --- a/ios/Tipsarr/Tipsarr/Features/Search/SearchView.swift +++ b/ios/Tipsarr/Tipsarr/Features/Search/SearchView.swift @@ -346,7 +346,7 @@ struct PersonBubble: View { PersonPhoto(path: person.profilePath).frame(width: 72, height: 72) Text(verbatim: person.name).font(.caption.weight(.medium)).lineLimit(1) if let department = person.department, !department.isEmpty { - Text(verbatim: department).font(.caption2).foregroundStyle(Tokens.palette.mutedFg).lineLimit(1) + Text(verbatim: L10n.department(department)).font(.caption2).foregroundStyle(Tokens.palette.mutedFg).lineLimit(1) } } .frame(width: 88) @@ -363,7 +363,7 @@ struct PersonRow: View { VStack(alignment: .leading, spacing: 2) { Text(verbatim: person.name).font(.body.weight(.medium)) if let department = person.department, !department.isEmpty { - Text(verbatim: department).font(.footnote).foregroundStyle(Tokens.palette.mutedFg) + Text(verbatim: L10n.department(department)).font(.footnote).foregroundStyle(Tokens.palette.mutedFg) } } Spacer() From 40d986fcd20352c088400e5e79af3f1447a18218 Mon Sep 17 00:00:00 2001 From: Rombond Date: Thu, 12 Nov 2026 10:27:10 -0500 Subject: [PATCH 3/8] Follow a new phone language and region without signing out iOS saved the phone's region only when the profile had none, so after a change of language and region it kept the old one (box office, release dates) until a new sign-in. The app now records the region it saved by itself and replaces it with the phone's current one at launch; a region picked in Settings (or on the web or Android) is kept. Language and region go in one update, and the tabs are rebuilt when the profile language changes too, since Discover was loaded with the old one. Server: a language change dropped nothing and only queued a background refresh, so the suggestion rows kept titles and posters in the old language until a suggestions.updated event reached the app. The rows are now deleted, and the next read builds them in the new language. --- backend/internal/api/users.go | 7 +++- backend/internal/server/suggestions_test.go | 13 +++++++ backend/internal/suggestions/service.go | 11 ++++++ docs/mobile-apps.md | 6 ++++ ios/Tipsarr/Tipsarr/App/MainTabView.swift | 27 +++++++------- .../Tipsarr/Core/Support/AppSettings.swift | 35 +++++++++++++++++++ .../Features/Settings/SettingsView.swift | 2 ++ 7 files changed, 86 insertions(+), 15 deletions(-) diff --git a/backend/internal/api/users.go b/backend/internal/api/users.go index 7399ebfb..e8e01010 100644 --- a/backend/internal/api/users.go +++ b/backend/internal/api/users.go @@ -3,6 +3,7 @@ package api import ( "context" "github.com/Rombond/tipsarr/backend/internal/teasers" + "log/slog" "net/http" "regexp" @@ -102,7 +103,11 @@ func registerUsers(api huma.API, d Deps) { return nil, err } if u.Language != before && d.Suggestions != nil { - d.Suggestions.QueueRefresh(u.ID) // stored suggestion rows hold titles in the old language + // stored suggestion rows hold titles in the old language: drop them, the next read builds them in the new one + if err := d.Suggestions.Forget(ctx, u.ID); err != nil { + slog.Warn("suggestions: forget after a language change", "user", u.ID, "err", err) + d.Suggestions.QueueRefresh(u.ID) + } } return &meOutput{Body: u}, nil }) diff --git a/backend/internal/server/suggestions_test.go b/backend/internal/server/suggestions_test.go index 76389621..4931144c 100644 --- a/backend/internal/server/suggestions_test.go +++ b/backend/internal/server/suggestions_test.go @@ -92,6 +92,19 @@ func TestSuggestions(t *testing.T) { t.Fatal("reading stored suggestions must not call TMDB") } + // a new language drops the stored rows (they hold titles in the old one): the next read builds them again at once + if resp, body := call(t, e.app, "PATCH", "/api/v1/me", `{"language":"fr"}`, admin); resp.StatusCode != 200 { + t.Fatalf("language change = %d %s", resp.StatusCode, body) + } + if stored, _, _ := e.st.SuggestionRows(ctx, alice); len(stored) != 0 { + t.Fatalf("rows kept after a language change: %d", len(stored)) + } + if again, _ := getSuggestions(t, e, admin); len(again.Rows) != 3 || again.Generating { + t.Fatalf("read after a language change = %+v", again) + } + call(t, e.app, "PATCH", "/api/v1/me", `{"language":""}`, admin) + getSuggestions(t, e, admin) + // bob has no history: seeded from what the server watches, no "because" rows bres, _ := getSuggestions(t, e, bob) if len(bres.Rows) != 1 || bres.Rows[0].Title != "Popular on this server" || bres.Rows[0].Personal { diff --git a/backend/internal/suggestions/service.go b/backend/internal/suggestions/service.go index e7cebbb9..2ee1f2da 100644 --- a/backend/internal/suggestions/service.go +++ b/backend/internal/suggestions/service.go @@ -292,6 +292,17 @@ func (s *Service) Force(ctx context.Context, userID string) error { return nil } +// Forget drops the stored rows of one user, so the next read builds them again (synchronously, like a first visit). +// Used when their language changed: the rows hold titles, posters and overviews in the old one, and a background +// refresh would leave them on screen until a suggestions.updated event reached the app. It waits for a running +// Generate, which would otherwise store rows in the old language after this. +func (s *Service) Forget(ctx context.Context, userID string) error { + lock := s.userLock(userID) + lock.Lock() + defer lock.Unlock() + return s.store.ReplaceSuggestions(ctx, userID, nil, nil) +} + // QueueRefresh schedules a debounced background regeneration (used when history changed), for the person and for // everyone whose recommendations borrow from them. The version check already refreshes those rows when they are next // read; this only makes it sooner. diff --git a/docs/mobile-apps.md b/docs/mobile-apps.md index fea5d50d..cda99fd0 100644 --- a/docs/mobile-apps.md +++ b/docs/mobile-apps.md @@ -18,6 +18,12 @@ To try the apps without a server, use the [demo](demo.md) address `https://tipsa as `admin` / `demo` (admin rights) or `user` / `demo` (plain user). Signing in starts your own sandbox; when it ends (30 minutes idle, 60 minutes at most) the app returns to its sign-in screen. Push notifications are off in the demo. +## Language and region + +The apps show titles and descriptions in the phone's language. The iOS app also takes the phone's region (release +dates, box office) until you pick one in Settings > Region: after you change the phone's language and region, the +next launch uses the new ones. A region you picked (in the app, on the web or on Android) is kept. + ## Notifications See [push notifications](push-notifications.md). The admin turns push on for the server; each person then allows diff --git a/ios/Tipsarr/Tipsarr/App/MainTabView.swift b/ios/Tipsarr/Tipsarr/App/MainTabView.swift index dac2d4d6..15527a3b 100644 --- a/ios/Tipsarr/Tipsarr/App/MainTabView.swift +++ b/ios/Tipsarr/Tipsarr/App/MainTabView.swift @@ -103,27 +103,26 @@ struct MainTabView: View { Task { try? await UNUserNotificationCenter.current().setBadgeCount(count) } } .task { - // The server knows the language the app shows (titles and descriptions follow it). + // The server knows the language the app shows (titles and descriptions follow it) and the region (release + // dates, box office): no region yet, or the one the app took from the iPhone earlier, becomes the iPhone's + // current one; a region the person picked is kept (see `AutoRegion`). One call, so the tabs below are + // rebuilt once and no second update is cancelled by that rebuild. let code = Bundle.main.preferredLocalizations.first ?? "en" - if !profile.language.lowercased().hasPrefix(code), !(profile.language.isEmpty && code == "en"), - let updated = try? await api.updatePreferences(language: code) { - session.update(profile: updated) - } - } - .task { - // No region yet: the iPhone's one, which the person can change in Settings. - if profile.region.isEmpty, let code = Locale.current.region?.identifier, code.count == 2, - let updated = try? await api.updatePreferences(region: code.uppercased()) { - session.update(profile: updated) - } + let language = !profile.language.lowercased().hasPrefix(code) && !(profile.language.isEmpty && code == "en") ? code : nil + let region = AutoRegion.pending(profileRegion: profile.region, deviceRegion: Locale.current.region?.identifier, accountID: account.id) + guard language != nil || region != nil, let updated = try? await api.updatePreferences(language: language, region: region) else { return } + if let region { AutoRegion.record(region, for: account.id) } + session.update(profile: updated) } .task { // People who manage requests see their pending count on the icon, so they are asked at once (alerts, sounds and the badge together). if profile.can(.manageRequests) { _ = try? await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) } await PushManager.shared.attach(account: account, pushAvailable: session.serverStatus?.pushAvailable ?? false) } - // A new language or account rebuilds the tabs, so every list is fetched again in that language. - .id("\(account.id)|\(settings.language ?? "")|\(profile.region)") + // A new language, region or account rebuilds the tabs, so every list is fetched again with them. The profile + // language counts too: it is updated above after the iPhone's language changed, and Discover was loaded + // with the old one in the meantime. + .id("\(account.id)|\(settings.language ?? "")|\(profile.language)|\(profile.region)") } /// The four tabs inside the system bar (iPhone, narrow windows), or with our own bar: a sidebar on a diff --git a/ios/Tipsarr/Tipsarr/Core/Support/AppSettings.swift b/ios/Tipsarr/Tipsarr/Core/Support/AppSettings.swift index 87f44fac..bd314ebb 100644 --- a/ios/Tipsarr/Tipsarr/Core/Support/AppSettings.swift +++ b/ios/Tipsarr/Tipsarr/Core/Support/AppSettings.swift @@ -69,3 +69,38 @@ final class AppSettings { language = nil } } + +/// The region the app saved by itself from the iPhone's, per account. While the profile still has that region, a new +/// iPhone region (after a language and region change) replaces it at the next launch. A region the person picked +/// is never replaced: one picked in Settings here is recorded as "" (picked), one picked on the web app or Android +/// differs from the recorded region. +enum AutoRegion { + private static func key(_ accountID: String) -> String { "autoRegion.\(accountID)" } + + /// nil: nothing recorded yet. "": the person picked the region in Settings. Else the region the app saved. + static func saved(for accountID: String) -> String? { + UserDefaults.standard.string(forKey: key(accountID)) + } + + static func record(_ region: String, for accountID: String) { + UserDefaults.standard.set(region, forKey: key(accountID)) + } + + /// The person picked a region: the app stops following the iPhone's. + static func markPicked(for accountID: String) { record("", for: accountID) } + + /// The iPhone's region to save on the profile, or nil to keep the profile's. + static func pending(profileRegion: String, deviceRegion: String?, accountID: String) -> String? { + guard let device = deviceRegion?.uppercased(), device.count == 2, device.allSatisfy(\.isLetter) else { return nil } + let current = profileRegion.uppercased() + if current.isEmpty { return device } + let saved = saved(for: accountID) + // Nothing recorded (signed in with an older build): a region equal to the iPhone's is taken as the app's own. + if saved == nil, current == device { + record(current, for: accountID) + return nil + } + guard current != device, let saved, saved.uppercased() == current else { return nil } + return device + } +} diff --git a/ios/Tipsarr/Tipsarr/Features/Settings/SettingsView.swift b/ios/Tipsarr/Tipsarr/Features/Settings/SettingsView.swift index d35e0968..dfca5832 100644 --- a/ios/Tipsarr/Tipsarr/Features/Settings/SettingsView.swift +++ b/ios/Tipsarr/Tipsarr/Features/Settings/SettingsView.swift @@ -255,6 +255,8 @@ struct RegionPickerScreen: View { private func pick(_ code: String) { if popsOnPick { dismiss() } + // A region picked here (even the current one) is the person's: the app no longer follows the iPhone's. + if let account = session.accounts.active { AutoRegion.markPicked(for: account.id) } guard let api = context?.api, code != selection else { return } Task { await savePreference(session: session, toast: toast) { try await api.updatePreferences(region: code) } } } From 73943a27cbd95eb5fe6fb22bc5ac2a3dc03d8d14 Mon Sep 17 00:00:00 2001 From: Rombond Date: Thu, 12 Nov 2026 12:02:27 -0500 Subject: [PATCH 4/8] iOS: device-neutral sign-out and settings wording (no "iPhone" on iPad) --- design/generated/Localizable.xcstrings | 20 ++++++++++---------- design/generated/strings.json | 20 ++++++++++---------- ios/Tipsarr/Tipsarr/Localizable.xcstrings | 20 ++++++++++---------- locales/en/mobile.json | 10 +++++----- locales/fr/mobile.json | 10 +++++----- 5 files changed, 40 insertions(+), 40 deletions(-) diff --git a/design/generated/Localizable.xcstrings b/design/generated/Localizable.xcstrings index b06994b9..9eaf2199 100644 --- a/design/generated/Localizable.xcstrings +++ b/design/generated/Localizable.xcstrings @@ -4539,13 +4539,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Signing out removes an account from this iPhone." + "value": "Signing out removes an account from this device." } }, "fr": { "stringUnit": { "state": "translated", - "value": "Se déconnecter retire un compte de cet iPhone." + "value": "Se déconnecter retire un compte de cet appareil." } } } @@ -7123,13 +7123,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Your password is kept in the iPhone’s Keychain, protected by Face ID or your passcode. It is only used to sign you back in when your session ends." + "value": "Your password is kept in the device’s Keychain, protected by Face ID or your passcode. It is only used to sign you back in when your session ends." } }, "fr": { "stringUnit": { "state": "translated", - "value": "Ton mot de passe est conservé dans le trousseau de l’iPhone, protégé par Face ID ou ton code. Il sert uniquement à te reconnecter quand ta session se termine." + "value": "Ton mot de passe est conservé dans le trousseau de l’appareil, protégé par Face ID ou ton code. Il sert uniquement à te reconnecter quand ta session se termine." } } } @@ -7155,13 +7155,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Default (iPhone language)" + "value": "Default (device language)" } }, "fr": { "stringUnit": { "state": "translated", - "value": "Par défaut (langue de l’iPhone)" + "value": "Par défaut (langue de l’appareil)" } } } @@ -7459,13 +7459,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Sign out of Tipsarr on this iPhone?" + "value": "Sign out of Tipsarr on this device?" } }, "fr": { "stringUnit": { "state": "translated", - "value": "Se déconnecter de Tipsarr sur cet iPhone ?" + "value": "Se déconnecter de Tipsarr sur cet appareil ?" } } } @@ -7507,13 +7507,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Follows your iPhone" + "value": "Follows your device" } }, "fr": { "stringUnit": { "state": "translated", - "value": "Suit ton iPhone" + "value": "Suit ton appareil" } } } diff --git a/design/generated/strings.json b/design/generated/strings.json index ed0f7af5..cc83dcc1 100644 --- a/design/generated/strings.json +++ b/design/generated/strings.json @@ -709,17 +709,17 @@ "m.accounts.add": "Add account", "m.accounts.manage": "Manage accounts", "m.accounts.switching": "Switching to {name}…", - "m.accounts.footer_signout": "Signing out removes an account from this iPhone.", + "m.accounts.footer_signout": "Signing out removes an account from this device.", "m.accounts.intro": "Each account keeps its own sign-in.", "m.accounts.server": "Server", "m.accounts.current_server": "Current server", "m.accounts.other_server": "Another server…", "m.faceid.usage": "Tipsarr uses Face ID to unlock the app.", "m.settings.theme_system": "System", - "m.settings.theme_system_hint": "Follows your iPhone", + "m.settings.theme_system_hint": "Follows your device", "m.settings.theme_footer": "Choose how Tipsarr looks. System follows the appearance set in iOS.", - "m.settings.language_default": "Default (iPhone language)", - "m.settings.sign_out_confirm": "Sign out of Tipsarr on this iPhone?", + "m.settings.language_default": "Default (device language)", + "m.settings.sign_out_confirm": "Sign out of Tipsarr on this device?", "m.picture.library": "Choose from library", "m.picture.footer": "Pictures are shared with other people on this server. Max 4 MB.", "m.detail.open_jellyfin": "Open in Jellyfin", @@ -805,7 +805,7 @@ "m.session.use_password": "Use my password", "m.session.signing_in_faceid": "Signing in…", "m.settings.faceid_signin": "Sign in with Face ID", - "m.settings.faceid_signin_footer": "Your password is kept in the iPhone’s Keychain, protected by Face ID or your passcode. It is only used to sign you back in when your session ends.", + "m.settings.faceid_signin_footer": "Your password is kept in the device’s Keychain, protected by Face ID or your passcode. It is only used to sign you back in when your session ends.", "m.detail.share": "Share", "m.admin.title": "Administration", "m.admin.sync": "Sync & jobs", @@ -1735,17 +1735,17 @@ "m.accounts.add": "Ajouter un compte", "m.accounts.manage": "Gérer les comptes", "m.accounts.switching": "Passage à {name}…", - "m.accounts.footer_signout": "Se déconnecter retire un compte de cet iPhone.", + "m.accounts.footer_signout": "Se déconnecter retire un compte de cet appareil.", "m.accounts.intro": "Chaque compte garde sa connexion.", "m.accounts.server": "Serveur", "m.accounts.current_server": "Serveur actuel", "m.accounts.other_server": "Un autre serveur…", "m.faceid.usage": "Tipsarr utilise Face ID pour déverrouiller l’app.", "m.settings.theme_system": "Système", - "m.settings.theme_system_hint": "Suit ton iPhone", + "m.settings.theme_system_hint": "Suit ton appareil", "m.settings.theme_footer": "Choisis l’apparence de Tipsarr. Système suit l’apparence réglée dans iOS.", - "m.settings.language_default": "Par défaut (langue de l’iPhone)", - "m.settings.sign_out_confirm": "Se déconnecter de Tipsarr sur cet iPhone ?", + "m.settings.language_default": "Par défaut (langue de l’appareil)", + "m.settings.sign_out_confirm": "Se déconnecter de Tipsarr sur cet appareil ?", "m.picture.library": "Choisir dans la photothèque", "m.picture.footer": "Les photos sont visibles par les autres personnes de ce serveur. 4 Mo maximum.", "m.detail.open_jellyfin": "Ouvrir dans Jellyfin", @@ -1831,7 +1831,7 @@ "m.session.use_password": "Utiliser mon mot de passe", "m.session.signing_in_faceid": "Connexion…", "m.settings.faceid_signin": "Se connecter avec Face ID", - "m.settings.faceid_signin_footer": "Ton mot de passe est conservé dans le trousseau de l’iPhone, protégé par Face ID ou ton code. Il sert uniquement à te reconnecter quand ta session se termine.", + "m.settings.faceid_signin_footer": "Ton mot de passe est conservé dans le trousseau de l’appareil, protégé par Face ID ou ton code. Il sert uniquement à te reconnecter quand ta session se termine.", "m.detail.share": "Partager", "m.admin.title": "Administration", "m.admin.sync": "Synchro et tâches", diff --git a/ios/Tipsarr/Tipsarr/Localizable.xcstrings b/ios/Tipsarr/Tipsarr/Localizable.xcstrings index b06994b9..9eaf2199 100644 --- a/ios/Tipsarr/Tipsarr/Localizable.xcstrings +++ b/ios/Tipsarr/Tipsarr/Localizable.xcstrings @@ -4539,13 +4539,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Signing out removes an account from this iPhone." + "value": "Signing out removes an account from this device." } }, "fr": { "stringUnit": { "state": "translated", - "value": "Se déconnecter retire un compte de cet iPhone." + "value": "Se déconnecter retire un compte de cet appareil." } } } @@ -7123,13 +7123,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Your password is kept in the iPhone’s Keychain, protected by Face ID or your passcode. It is only used to sign you back in when your session ends." + "value": "Your password is kept in the device’s Keychain, protected by Face ID or your passcode. It is only used to sign you back in when your session ends." } }, "fr": { "stringUnit": { "state": "translated", - "value": "Ton mot de passe est conservé dans le trousseau de l’iPhone, protégé par Face ID ou ton code. Il sert uniquement à te reconnecter quand ta session se termine." + "value": "Ton mot de passe est conservé dans le trousseau de l’appareil, protégé par Face ID ou ton code. Il sert uniquement à te reconnecter quand ta session se termine." } } } @@ -7155,13 +7155,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Default (iPhone language)" + "value": "Default (device language)" } }, "fr": { "stringUnit": { "state": "translated", - "value": "Par défaut (langue de l’iPhone)" + "value": "Par défaut (langue de l’appareil)" } } } @@ -7459,13 +7459,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Sign out of Tipsarr on this iPhone?" + "value": "Sign out of Tipsarr on this device?" } }, "fr": { "stringUnit": { "state": "translated", - "value": "Se déconnecter de Tipsarr sur cet iPhone ?" + "value": "Se déconnecter de Tipsarr sur cet appareil ?" } } } @@ -7507,13 +7507,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Follows your iPhone" + "value": "Follows your device" } }, "fr": { "stringUnit": { "state": "translated", - "value": "Suit ton iPhone" + "value": "Suit ton appareil" } } } diff --git a/locales/en/mobile.json b/locales/en/mobile.json index cfbdbea3..a1aec95e 100644 --- a/locales/en/mobile.json +++ b/locales/en/mobile.json @@ -29,17 +29,17 @@ "m.accounts.add": "Add account", "m.accounts.manage": "Manage accounts", "m.accounts.switching": "Switching to {name}…", - "m.accounts.footer_signout": "Signing out removes an account from this iPhone.", + "m.accounts.footer_signout": "Signing out removes an account from this device.", "m.accounts.intro": "Each account keeps its own sign-in.", "m.accounts.server": "Server", "m.accounts.current_server": "Current server", "m.accounts.other_server": "Another server…", "m.faceid.usage": "Tipsarr uses Face ID to unlock the app.", "m.settings.theme_system": "System", - "m.settings.theme_system_hint": "Follows your iPhone", + "m.settings.theme_system_hint": "Follows your device", "m.settings.theme_footer": "Choose how Tipsarr looks. System follows the appearance set in iOS.", - "m.settings.language_default": "Default (iPhone language)", - "m.settings.sign_out_confirm": "Sign out of Tipsarr on this iPhone?", + "m.settings.language_default": "Default (device language)", + "m.settings.sign_out_confirm": "Sign out of Tipsarr on this device?", "m.picture.library": "Choose from library", "m.picture.footer": "Pictures are shared with other people on this server. Max 4 MB.", "m.detail.open_jellyfin": "Open in Jellyfin", @@ -125,7 +125,7 @@ "m.session.use_password": "Use my password", "m.session.signing_in_faceid": "Signing in…", "m.settings.faceid_signin": "Sign in with Face ID", - "m.settings.faceid_signin_footer": "Your password is kept in the iPhone’s Keychain, protected by Face ID or your passcode. It is only used to sign you back in when your session ends.", + "m.settings.faceid_signin_footer": "Your password is kept in the device’s Keychain, protected by Face ID or your passcode. It is only used to sign you back in when your session ends.", "m.detail.share": "Share", "m.admin.title": "Administration", "m.admin.sync": "Sync & jobs", diff --git a/locales/fr/mobile.json b/locales/fr/mobile.json index 6bdf3b38..42f41317 100644 --- a/locales/fr/mobile.json +++ b/locales/fr/mobile.json @@ -29,17 +29,17 @@ "m.accounts.add": "Ajouter un compte", "m.accounts.manage": "Gérer les comptes", "m.accounts.switching": "Passage à {name}…", - "m.accounts.footer_signout": "Se déconnecter retire un compte de cet iPhone.", + "m.accounts.footer_signout": "Se déconnecter retire un compte de cet appareil.", "m.accounts.intro": "Chaque compte garde sa connexion.", "m.accounts.server": "Serveur", "m.accounts.current_server": "Serveur actuel", "m.accounts.other_server": "Un autre serveur…", "m.faceid.usage": "Tipsarr utilise Face ID pour déverrouiller l’app.", "m.settings.theme_system": "Système", - "m.settings.theme_system_hint": "Suit ton iPhone", + "m.settings.theme_system_hint": "Suit ton appareil", "m.settings.theme_footer": "Choisis l’apparence de Tipsarr. Système suit l’apparence réglée dans iOS.", - "m.settings.language_default": "Par défaut (langue de l’iPhone)", - "m.settings.sign_out_confirm": "Se déconnecter de Tipsarr sur cet iPhone ?", + "m.settings.language_default": "Par défaut (langue de l’appareil)", + "m.settings.sign_out_confirm": "Se déconnecter de Tipsarr sur cet appareil ?", "m.picture.library": "Choisir dans la photothèque", "m.picture.footer": "Les photos sont visibles par les autres personnes de ce serveur. 4 Mo maximum.", "m.detail.open_jellyfin": "Ouvrir dans Jellyfin", @@ -125,7 +125,7 @@ "m.session.use_password": "Utiliser mon mot de passe", "m.session.signing_in_faceid": "Connexion…", "m.settings.faceid_signin": "Se connecter avec Face ID", - "m.settings.faceid_signin_footer": "Ton mot de passe est conservé dans le trousseau de l’iPhone, protégé par Face ID ou ton code. Il sert uniquement à te reconnecter quand ta session se termine.", + "m.settings.faceid_signin_footer": "Ton mot de passe est conservé dans le trousseau de l’appareil, protégé par Face ID ou ton code. Il sert uniquement à te reconnecter quand ta session se termine.", "m.detail.share": "Partager", "m.admin.title": "Administration", "m.admin.sync": "Synchro et tâches", From a17f136357ec5d1e6ff77b35347dfb67eadad79d Mon Sep 17 00:00:00 2001 From: Rombond Date: Thu, 12 Nov 2026 12:02:41 -0500 Subject: [PATCH 5/8] iOS: Profile sheet at page size on iPad, so most-watched captions and Statistics fit --- ios/Tipsarr/Tipsarr/App/MainTabView.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ios/Tipsarr/Tipsarr/App/MainTabView.swift b/ios/Tipsarr/Tipsarr/App/MainTabView.swift index 15527a3b..ddfe0a24 100644 --- a/ios/Tipsarr/Tipsarr/App/MainTabView.swift +++ b/ios/Tipsarr/Tipsarr/App/MainTabView.swift @@ -51,7 +51,8 @@ struct MainTabView: View { router.profileOpen = false router.tab = .requests } - .tipsarrSheet(detents: [.large]) + // Page size on iPad: the form size cut the most-watched posters and their captions, and a form sheet does not grow. + .tipsarrSheet(detents: [.large], sizing: .page) } .sheet(isPresented: $router.settingsOpen) { SettingsScreen(account: account, profile: profile, path: $router.settingsPath) { router.settingsOpen = false } From 0d9a9e9c5c83addcd69b00239d6b09f6aab2080d Mon Sep 17 00:00:00 2001 From: Rombond Date: Thu, 12 Nov 2026 12:02:41 -0500 Subject: [PATCH 6/8] iOS: close Settings and Profile before signing out, no notification prompt while signing out --- ios/Tipsarr/Tipsarr/App/MainTabView.swift | 11 ++++++++++- ios/Tipsarr/Tipsarr/Core/Auth/SessionManager.swift | 10 +++++++++- ios/Tipsarr/Tipsarr/Core/Push/PushManager.swift | 3 ++- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/ios/Tipsarr/Tipsarr/App/MainTabView.swift b/ios/Tipsarr/Tipsarr/App/MainTabView.swift index ddfe0a24..87599c2e 100644 --- a/ios/Tipsarr/Tipsarr/App/MainTabView.swift +++ b/ios/Tipsarr/Tipsarr/App/MainTabView.swift @@ -87,6 +87,11 @@ struct MainTabView: View { for teaser in teasersModel.items.prefix(2) { _ = teaserVideos.video(for: teaser) } } .onChange(of: profile.ratingSource) { _, new in ratings.setSource(RatingSource(rawValue: new) ?? .tmdb) } + .onChange(of: session.signingOut) { _, leaving in + guard leaving else { return } + router.settingsOpen = false + router.profileOpen = false + } .task(id: router.tab) { await refreshPending() } .onChange(of: scenePhase) { _, phase in if phase == .active { Task { await refreshPending(); await session.refreshStatus() } } @@ -117,7 +122,11 @@ struct MainTabView: View { } .task { // People who manage requests see their pending count on the icon, so they are asked at once (alerts, sounds and the badge together). - if profile.can(.manageRequests) { _ = try? await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) } + // Never while signing out: the system prompt would then show over Connect. + if profile.can(.manageRequests), !session.signingOut, case .ready = session.phase, !Task.isCancelled { + _ = try? await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) + } + guard !Task.isCancelled, !session.signingOut else { return } await PushManager.shared.attach(account: account, pushAvailable: session.serverStatus?.pushAvailable ?? false) } // A new language, region or account rebuilds the tabs, so every list is fetched again with them. The profile diff --git a/ios/Tipsarr/Tipsarr/Core/Auth/SessionManager.swift b/ios/Tipsarr/Tipsarr/Core/Auth/SessionManager.swift index cb2c4f0a..3fa34f80 100644 --- a/ios/Tipsarr/Tipsarr/Core/Auth/SessionManager.swift +++ b/ios/Tipsarr/Tipsarr/Core/Auth/SessionManager.swift @@ -118,8 +118,16 @@ final class SessionManager { } } - /// Removes the account from the phone; the next one (or Connect) takes over. + /// True from the sign-out confirmation until the account is gone: the tabs close their sheets first. + private(set) var signingOut = false + + /// Removes the account from the device; the next one (or Connect) takes over. func signOut(_ account: Account) async { + // The Settings (or Profile) sheet is closed before the tabs go away: on iPad a sheet whose tabs were replaced + // under it stayed on screen over Connect, showing zero accounts. + signingOut = true + defer { signingOut = false } + try? await Task.sleep(for: .milliseconds(400)) CredentialStore.delete(accountID: account.id) await PushManager.shared.detach(account: account) await TipsarrAPI(serverURL: account.serverURL, token: account.token).logout() diff --git a/ios/Tipsarr/Tipsarr/Core/Push/PushManager.swift b/ios/Tipsarr/Tipsarr/Core/Push/PushManager.swift index c2672b97..5b3382ee 100644 --- a/ios/Tipsarr/Tipsarr/Core/Push/PushManager.swift +++ b/ios/Tipsarr/Tipsarr/Core/Push/PushManager.swift @@ -64,7 +64,8 @@ final class PushManager { /// Before sign-out: stop alerts for this account on this device. func detach(account: Account) async { _ = try? await TipsarrAPI(serverURL: account.serverURL, token: account.token).unregisterDevice() - if self.account?.id == account.id { registered = false } + // Signed out: nothing may ask for permission or register for this account any more. + if self.account?.id == account.id { registered = false; self.account = nil } } func didRegister(deviceToken data: Data) { From 5e2f99c1a97640ec66ebe13cac01787c4d5fba51 Mon Sep 17 00:00:00 2001 From: Rombond Date: Thu, 12 Nov 2026 12:02:41 -0500 Subject: [PATCH 7/8] iOS: request sheet fits its content on iPad, Request and Cancel pinned under the form --- .../Tipsarr/DesignSystem/SheetChrome.swift | 30 ++++++++----- .../Features/Detail/RequestSheet.swift | 44 +++++++++++++------ 2 files changed, 50 insertions(+), 24 deletions(-) diff --git a/ios/Tipsarr/Tipsarr/DesignSystem/SheetChrome.swift b/ios/Tipsarr/Tipsarr/DesignSystem/SheetChrome.swift index d0f17678..58616168 100644 --- a/ios/Tipsarr/Tipsarr/DesignSystem/SheetChrome.swift +++ b/ios/Tipsarr/Tipsarr/DesignSystem/SheetChrome.swift @@ -24,18 +24,28 @@ extension View { extension View { /// A bottom sheet as tall as its content (`contentHeight`, measured by the sheet), up to `maxHeight`; drag up for - /// the full height. iOS 26 adds the bottom safe area to a custom height on a floating sheet (empty space under the - /// last button), so it is taken off here. - func fittedSheetDetents(_ contentHeight: CGFloat, maxHeight: CGFloat = 640) -> some View { - let height = contentHeight > 0 ? max(contentHeight - Self.windowBottomInset, 1) : 0 - return presentationDetents([.height(min(height, maxHeight)), .large]) + /// the full height. `pinnedFooter`: the content ends with a `safeAreaInset` bar that already stays clear of the + /// sheet's bottom safe area, so none of it is taken off. + func fittedSheetDetents(_ contentHeight: CGFloat, maxHeight: CGFloat = 640, pinnedFooter: Bool = false) -> some View { + modifier(FittedSheetDetents(contentHeight: contentHeight, maxHeight: maxHeight, pinnedFooter: pinnedFooter)) } +} + +/// iOS 26 adds the sheet's bottom safe area to a custom height on a floating iPhone sheet (empty space under the last +/// button of a scrolling form), so it is taken off. The inset is the sheet's own, not the window's: an iPad sheet floats +/// above the home indicator without one and gets exactly the height asked for (taking the window's inset off cut its +/// last line). +private struct FittedSheetDetents: ViewModifier { + let contentHeight: CGFloat + let maxHeight: CGFloat + let pinnedFooter: Bool + @State private var bottomInset: CGFloat = 0 - @MainActor private static var windowBottomInset: CGFloat { - UIApplication.shared.connectedScenes - .compactMap { $0 as? UIWindowScene } - .flatMap(\.windows) - .first(where: \.isKeyWindow)?.safeAreaInsets.bottom ?? 0 + func body(content: Content) -> some View { + let height = contentHeight > 0 ? max(contentHeight - (pinnedFooter ? 0 : bottomInset), 1) : 0 + content + .onGeometryChange(for: CGFloat.self, of: { $0.safeAreaInsets.bottom }) { bottomInset = $0 } + .presentationDetents([.height(min(height, maxHeight)), .large]) } } diff --git a/ios/Tipsarr/Tipsarr/Features/Detail/RequestSheet.swift b/ios/Tipsarr/Tipsarr/Features/Detail/RequestSheet.swift index 83efa0d5..97c8b203 100644 --- a/ios/Tipsarr/Tipsarr/Features/Detail/RequestSheet.swift +++ b/ios/Tipsarr/Tipsarr/Features/Detail/RequestSheet.swift @@ -10,7 +10,10 @@ struct RequestSheet: View { @State private var profileID: Int? @State private var folder: String? @State private var errorText: String? - @State private var contentHeight: CGFloat = 560 + @State private var formHeight: CGFloat = 460 + @State private var actionsHeight: CGFloat = 100 + /// The sheet's bottom safe area (iPhone home indicator): the buttons need less padding above it. + @State private var bottomInset: CGFloat = 0 private var detail: MediaDetail? { model.detail } @@ -37,28 +40,41 @@ struct RequestSheet: View { .font(.footnote) .foregroundStyle(Tokens.palette.mutedFg) if let errorText { Banner(kind: .error, title: LText.verbatim(errorText)) } - VStack(spacing: Tokens.Spacing.sm) { - Button { Task { await submit() } } label: { - Label { - if model.busy { Text("media.requesting") } else { Text("media.request") } - } icon: { Image(systemName: "plus") } - } - .buttonStyle(.tipsarr(.primary, fullWidth: true)) - .disabled(model.busy || (isTV && selectedSeasons.isEmpty)) - Button("common.cancel") { dismiss() } - .buttonStyle(.tipsarr(.tonal, fullWidth: true)) - } } .padding(Tokens.Spacing.xl) .padding(.top, Tokens.Spacing.md) - .onGeometryChange(for: CGFloat.self, of: { $0.size.height }) { contentHeight = $0 } + .onGeometryChange(for: CGFloat.self, of: { $0.size.height }) { formHeight = $0 } } .scrollBounceBehavior(.basedOnSize) + // Pinned under the form: whatever height the sheet gets (a shorter detent, a long list of seasons, an iPad + // window), Request and Cancel stay on screen and the form scrolls above them. + .safeAreaInset(edge: .bottom, spacing: 0) { + actions + .padding(.horizontal, Tokens.Spacing.xl) + .padding(.bottom, bottomInset > 0 ? Tokens.Spacing.sm : Tokens.Spacing.xl) + .background(Tokens.palette.bg) + .onGeometryChange(for: CGFloat.self, of: { $0.size.height }) { actionsHeight = $0 } + } + .onGeometryChange(for: CGFloat.self, of: { $0.safeAreaInsets.bottom }) { bottomInset = $0 } // Only as tall as the form needs (a movie), up to the full height (a long list of seasons). - .fittedSheetDetents(contentHeight) + .fittedSheetDetents(formHeight + actionsHeight, pinnedFooter: true) .onAppear(perform: setDefaults) } + private var actions: some View { + VStack(spacing: Tokens.Spacing.sm) { + Button { Task { await submit() } } label: { + Label { + if model.busy { Text("media.requesting") } else { Text("media.request") } + } icon: { Image(systemName: "plus") } + } + .buttonStyle(.tipsarr(.primary, fullWidth: true)) + .disabled(model.busy || (isTV && selectedSeasons.isEmpty)) + Button("common.cancel") { dismiss() } + .buttonStyle(.tipsarr(.tonal, fullWidth: true)) + } + } + private var header: some View { HStack(spacing: Tokens.Spacing.md) { RemoteImage(path: detail?.posterPath, size: .w185) { From 37979ac64e88abb7e502f734a67ad4811539b628 Mon Sep 17 00:00:00 2001 From: Rombond Date: Thu, 12 Nov 2026 12:02:41 -0500 Subject: [PATCH 8/8] iOS: wider Search column on iPad so the scope segments are not cut --- ios/Tipsarr/Tipsarr/Features/Search/SearchView.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ios/Tipsarr/Tipsarr/Features/Search/SearchView.swift b/ios/Tipsarr/Tipsarr/Features/Search/SearchView.swift index 205e3685..029f5bed 100644 --- a/ios/Tipsarr/Tipsarr/Features/Search/SearchView.swift +++ b/ios/Tipsarr/Tipsarr/Features/Search/SearchView.swift @@ -62,8 +62,11 @@ struct SearchView: View { private var splitBody: some View { NavigationSplitView(columnVisibility: .constant(.all)) { searchable(pane, placement: .sidebar) - .navigationSplitViewColumnWidth(min: 340, ideal: 400, max: 460) .mediaDestinations() + .toolbar(removing: .sidebarToggle) + // Last: put before `.navigationDestination` it was ignored, the column stayed at the 320 pt system + // width and the scope segments were cut ("Perso…"). + .navigationSplitViewColumnWidth(min: 360, ideal: 400, max: 460) } detail: { NavigationStack { if let selection {