1
0
forked from firka/firka

Improve watch UI and robust language sync

Several watch UI and sync improvements: clamp CountdownRing values and use clamped values for progress/color/display; add optional backgroundColor to FirkaCard and refactor HomeView to use lessonTitleWithStatus, lessonCardBackgroundColor and improved break time calculation and refresh handling (onChange of lastUpdated). TimetableView now shows status icons/colors via helper methods. DataStore now returns a localized "time_now" for recent updates. WatchSessionManager handles Flutter channel/unready states by serving shared language state when available and avoids empty language replies. Dart fixes: await initLang in settings, return null from WatchSyncHelper when uninitialized, and attempt to publish language to watch after initialization in main.
This commit is contained in:
Horváth Gergely
2026-02-20 11:03:47 +01:00
committed by 4831c0
parent 9a99a6869a
commit 146124228a
9 changed files with 212 additions and 52 deletions

View File

@@ -8,18 +8,23 @@ struct CountdownRing: View {
var lineWidth: CGFloat = 8
var displayOffset: Int = 0 // Add to displayed minutes (e.g., +1)
private var clampedRemainingMinutes: Int {
guard totalMinutes > 0 else { return 0 }
return max(0, min(remainingMinutes, totalMinutes))
}
var progress: Double {
guard totalMinutes > 0 else { return 0 }
return Double(totalMinutes - remainingMinutes) / Double(totalMinutes)
return Double(totalMinutes - clampedRemainingMinutes) / Double(totalMinutes)
}
var displayedMinutes: Int {
remainingMinutes + displayOffset
max(0, remainingMinutes + displayOffset)
}
var ringColor: Color {
if remainingMinutes < 5 { return .red }
if remainingMinutes < 10 { return .yellow }
if clampedRemainingMinutes < 5 { return .red }
if clampedRemainingMinutes < 10 { return .yellow }
return .green
}

View File

@@ -3,16 +3,25 @@ import SwiftUI
struct FirkaCard<Content: View>: View {
let content: Content
var isHighlighted: Bool = false
var backgroundColor: Color? = nil
init(isHighlighted: Bool = false, @ViewBuilder content: () -> Content) {
init(
isHighlighted: Bool = false,
backgroundColor: Color? = nil,
@ViewBuilder content: () -> Content
) {
self.isHighlighted = isHighlighted
self.backgroundColor = backgroundColor
self.content = content()
}
var body: some View {
content
.padding(12)
.background(isHighlighted ? Color.green.opacity(0.2) : Color(white: 0.12))
.background(
backgroundColor ??
(isHighlighted ? Color.green.opacity(0.2) : Color(white: 0.12))
)
.cornerRadius(12)
}
}

View File

@@ -505,7 +505,7 @@ class DataStore {
let elapsed = Date().timeIntervalSince(lastUpdated)
if elapsed < 60 {
return nil
return "time_now".localized
}
// Minutes

View File

@@ -93,10 +93,10 @@ struct HomeView: View {
.disabled(dataStore.isLoading || refreshStatus == .loading)
.padding(.top, 8)
.onChange(of: dataStore.isLoading) { oldValue, newValue in
if newValue && refreshStatus == .idle {
if newValue && refreshStatus != .loading {
wasLoadingFromBackground = true
}
if !newValue && wasLoadingFromBackground && refreshStatus == .idle {
if !newValue && wasLoadingFromBackground && refreshStatus != .loading {
wasLoadingFromBackground = false
if dataStore.error == nil && dataStore.data != nil {
refreshStatus = .success
@@ -111,6 +111,20 @@ struct HomeView: View {
}
}
}
.onChange(of: dataStore.lastUpdated) { oldValue, newValue in
guard let oldValue, let newValue else { return }
guard newValue > oldValue else { return }
guard dataStore.error == nil else { return }
guard refreshStatus != .loading else { return }
refreshStatus = .success
Task {
try? await Task.sleep(nanoseconds: 2_000_000_000)
if refreshStatus == .success {
refreshStatus = .idle
}
}
}
}
private var refreshStatusText: String {
@@ -196,12 +210,20 @@ struct HomeView: View {
displayOffset: 1
)
.id("lesson-\(lesson.start.timeIntervalSince1970)")
FirkaCard(isHighlighted: true) {
FirkaCard(
isHighlighted: true,
backgroundColor: lessonCardBackgroundColor(
for: lesson,
isHighlighted: true
)
) {
VStack(alignment: .leading, spacing: 4) {
Text(lesson.displayName)
.font(.subheadline)
.fontWeight(.semibold)
.lineLimit(2)
lessonTitleWithStatus(
lesson,
font: .subheadline,
weight: .semibold,
lineLimit: 2
)
HStack(spacing: 6) {
if let room = lesson.roomName {
@@ -222,11 +244,15 @@ struct HomeView: View {
.font(.caption)
.foregroundColor(.secondary)
FirkaCard {
FirkaCard(backgroundColor: lessonCardBackgroundColor(for: next)) {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(next.displayName)
.font(.subheadline)
lessonTitleWithStatus(
next,
font: .subheadline,
weight: .regular,
lineLimit: 2
)
if let room = next.roomName {
Text(room)
.font(.caption2)
@@ -252,11 +278,16 @@ struct HomeView: View {
.font(.caption)
.foregroundColor(.secondary)
let remaining = max(0, Int(next.start.timeIntervalSince(now) / 60))
let remaining = max(0, Int(ceil(next.start.timeIntervalSince(now) / 60)))
let totalBreakMinutes: Int = {
guard let previous = previousLesson else { return max(remaining, 1) }
let breakSeconds = max(60, next.start.timeIntervalSince(previous.end))
return max(1, Int(ceil(breakSeconds / 60)))
}()
HStack(spacing: 10) {
CountdownRing(
totalMinutes: 15,
totalMinutes: totalBreakMinutes,
remainingMinutes: remaining,
label: "minutes".localized,
size: 56,
@@ -265,12 +296,14 @@ struct HomeView: View {
)
.id("break-\(next.start.timeIntervalSince1970)")
FirkaCard {
FirkaCard(backgroundColor: lessonCardBackgroundColor(for: next)) {
VStack(alignment: .leading, spacing: 4) {
Text("next_lesson".localized(next.displayName))
.font(.subheadline)
.fontWeight(.semibold)
.lineLimit(2)
HStack(spacing: 4) {
Text("next_lesson".localized(next.displayName))
.font(.subheadline)
.fontWeight(.semibold)
.lineLimit(2)
}
HStack(spacing: 6) {
if let room = next.roomName {
@@ -296,10 +329,14 @@ struct HomeView: View {
.font(.caption)
.foregroundColor(.secondary)
FirkaCard {
FirkaCard(backgroundColor: lessonCardBackgroundColor(for: first)) {
VStack(alignment: .leading, spacing: 8) {
Text(first.displayName)
.font(.headline)
lessonTitleWithStatus(
first,
font: .headline,
weight: .regular,
lineLimit: 2
)
HStack {
if let room = first.roomName {
@@ -342,10 +379,14 @@ struct HomeView: View {
.foregroundColor(.secondary)
.padding(.top, 8)
FirkaCard {
FirkaCard(backgroundColor: lessonCardBackgroundColor(for: nextLesson)) {
HStack {
Text(nextLesson.displayName)
.font(.subheadline)
lessonTitleWithStatus(
nextLesson,
font: .subheadline,
weight: .regular,
lineLimit: 2
)
Spacer()
Text(nextLesson.start, style: .time)
.font(.caption)
@@ -420,6 +461,46 @@ struct HomeView: View {
}
}
@ViewBuilder
private func lessonTitleWithStatus(
_ lesson: WidgetLesson,
font: Font,
weight: Font.Weight = .regular,
lineLimit: Int = 2
) -> some View {
Text(lesson.displayName)
.font(font)
.fontWeight(weight)
.lineLimit(lineLimit)
.foregroundColor(lessonPrimaryTextColor(for: lesson))
}
private func lessonPrimaryTextColor(for lesson: WidgetLesson) -> Color {
if lesson.isCancelled {
return .red
}
if lesson.isSubstitution {
return .yellow
}
return .primary
}
private func lessonCardBackgroundColor(
for lesson: WidgetLesson,
isHighlighted: Bool = false
) -> Color {
if lesson.isCancelled {
return Color.red.opacity(0.16)
}
if lesson.isSubstitution {
return Color.yellow.opacity(0.16)
}
if isHighlighted {
return Color.green.opacity(0.2)
}
return Color(white: 0.12)
}
// MARK: - Break/Vacation View
@ViewBuilder

View File

@@ -308,17 +308,17 @@ struct TimetableView: View {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(lesson.displayName)
.font(.subheadline)
.fontWeight(.medium)
.lineLimit(1)
.strikethrough(lesson.isCancelled)
.opacity(lesson.isCancelled ? 0.5 : 1)
HStack(spacing: 4) {
Text(lesson.displayName)
.font(.subheadline)
.fontWeight(.medium)
.lineLimit(1)
if lesson.isSubstitution {
Image(systemName: "exclamationmark.circle.fill")
.font(.caption2)
.foregroundColor(.orange)
if let statusIcon = lessonStatusIconName(for: lesson) {
Image(systemName: statusIcon)
.font(.caption2)
.foregroundColor(lessonStatusColor(for: lesson))
}
}
Spacer()
@@ -340,11 +340,23 @@ struct TimetableView: View {
}
.font(.caption2)
.foregroundColor(.secondary)
.opacity(lesson.isCancelled ? 0.5 : 1)
}
}
}
.opacity(lesson.isCancelled ? 0.6 : 1)
}
private func lessonStatusIconName(for lesson: WidgetLesson) -> String? {
if lesson.isCancelled {
return "xmark.circle.fill"
}
if lesson.isSubstitution {
return "exclamationmark.circle.fill"
}
return nil
}
private func lessonStatusColor(for lesson: WidgetLesson) -> Color {
lesson.isCancelled ? .red : .yellow
}
}

View File

@@ -706,22 +706,64 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
}
case "requestLanguage":
if !self.isFlutterWatchSyncReady {
if let sharedState = SharedLanguageStateManager.shared.loadState() {
print("[WatchSessionManager] Flutter not ready for language request, serving shared language: \(sharedState.languageCode)")
replyHandler([
"language": sharedState.languageCode,
"language_state_version": sharedState.stateVersion
])
} else {
print("[WatchSessionManager] Flutter not ready for language request and no shared language is available")
replyHandler(["error": "language_not_ready"])
}
return
}
guard let flutterChannel = self.flutterChannel else {
if let sharedState = SharedLanguageStateManager.shared.loadState() {
print("[WatchSessionManager] Flutter channel missing for language request, serving shared language: \(sharedState.languageCode)")
replyHandler([
"language": sharedState.languageCode,
"language_state_version": sharedState.stateVersion
])
} else {
print("[WatchSessionManager] Flutter channel missing for language request and no shared language is available")
replyHandler(["error": "language_not_ready"])
}
return
}
DispatchQueue.main.async {
self.flutterChannel?.invokeMethod("getLanguageForWatch", arguments: nil) { result in
if let languageCode = result as? String {
let sharedState = SharedLanguageStateManager.shared.publishState(languageCode: languageCode)
flutterChannel.invokeMethod("getLanguageForWatch", arguments: nil) { result in
if let languageCode = result as? String, !languageCode.isEmpty {
if let existingState = SharedLanguageStateManager.shared.loadState(),
existingState.languageCode == languageCode {
print("[WatchSessionManager] Sending language to Watch from shared cache: \(languageCode)")
replyHandler([
"language": languageCode,
"language_state_version": existingState.stateVersion
])
return
}
let sharedState = SharedLanguageStateManager.shared.publishState(
languageCode: languageCode
)
print("[WatchSessionManager] Sending language to Watch: \(languageCode)")
replyHandler([
"language": languageCode,
"language_state_version": sharedState.stateVersion
])
} else {
let sharedState = SharedLanguageStateManager.shared.publishState(languageCode: "hu")
print("[WatchSessionManager] No language from Flutter, defaulting to hu")
} else if let sharedState = SharedLanguageStateManager.shared.loadState() {
print("[WatchSessionManager] No language from Flutter, serving last shared language: \(sharedState.languageCode)")
replyHandler([
"language": "hu",
"language": sharedState.languageCode,
"language_state_version": sharedState.stateVersion
])
} else {
print("[WatchSessionManager] No language available from Flutter or shared state")
replyHandler(["error": "language_not_ready"])
}
}
}

View File

@@ -163,7 +163,7 @@ class SettingsStore {
],
0,
always, () async {
initLang(initData);
await initLang(initData);
initData.settings = SettingsStore(initData.l10n);
await initData.settings.load(initData.isar.appSettingsModels);

View File

@@ -704,8 +704,9 @@ class WatchSyncHelper {
static String? _getLanguageForWatch() {
if (!initDone) {
debugPrint('[WatchSync] App not initialized, returning default language');
return 'hu';
debugPrint(
'[WatchSync] App not initialized yet, language unavailable for Watch');
return null;
}
final languageCode = initData.l10n.localeName;

View File

@@ -533,6 +533,16 @@ class InitializationScreen extends StatelessWidget {
FlutterNativeSplash.remove();
WatchSyncHelper.initialize();
if (Platform.isIOS) {
unawaited(() async {
try {
await WatchSyncHelper.sendLanguageToWatch();
} catch (e) {
logger.warning(
'[Init] Failed to publish language to Watch after sync init: $e');
}
}());
}
var watch = WatchConnectivity();