diff --git a/firka/ios/FirkaWatch Watch App/Components/CountdownRing.swift b/firka/ios/FirkaWatch Watch App/Components/CountdownRing.swift index bd1a13c5..8841a507 100644 --- a/firka/ios/FirkaWatch Watch App/Components/CountdownRing.swift +++ b/firka/ios/FirkaWatch Watch App/Components/CountdownRing.swift @@ -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 } diff --git a/firka/ios/FirkaWatch Watch App/Components/FirkaCard.swift b/firka/ios/FirkaWatch Watch App/Components/FirkaCard.swift index ddd7a9fb..b74be370 100644 --- a/firka/ios/FirkaWatch Watch App/Components/FirkaCard.swift +++ b/firka/ios/FirkaWatch Watch App/Components/FirkaCard.swift @@ -3,16 +3,25 @@ import SwiftUI struct FirkaCard: 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) } } diff --git a/firka/ios/FirkaWatch Watch App/Services/DataStore.swift b/firka/ios/FirkaWatch Watch App/Services/DataStore.swift index f7a2cd61..c4007e2c 100644 --- a/firka/ios/FirkaWatch Watch App/Services/DataStore.swift +++ b/firka/ios/FirkaWatch Watch App/Services/DataStore.swift @@ -505,7 +505,7 @@ class DataStore { let elapsed = Date().timeIntervalSince(lastUpdated) if elapsed < 60 { - return nil + return "time_now".localized } // Minutes diff --git a/firka/ios/FirkaWatch Watch App/Views/HomeView.swift b/firka/ios/FirkaWatch Watch App/Views/HomeView.swift index c7c9ab80..84bf4ba9 100644 --- a/firka/ios/FirkaWatch Watch App/Views/HomeView.swift +++ b/firka/ios/FirkaWatch Watch App/Views/HomeView.swift @@ -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 diff --git a/firka/ios/FirkaWatch Watch App/Views/TimetableView.swift b/firka/ios/FirkaWatch Watch App/Views/TimetableView.swift index 1d2e573f..3cb473f4 100644 --- a/firka/ios/FirkaWatch Watch App/Views/TimetableView.swift +++ b/firka/ios/FirkaWatch Watch App/Views/TimetableView.swift @@ -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 } } diff --git a/firka/ios/Runner/WatchSessionManager.swift b/firka/ios/Runner/WatchSessionManager.swift index 4d108a0c..be35d628 100644 --- a/firka/ios/Runner/WatchSessionManager.swift +++ b/firka/ios/Runner/WatchSessionManager.swift @@ -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"]) } } } diff --git a/firka/lib/helpers/settings.dart b/firka/lib/helpers/settings.dart index bc7eb5bf..808a9913 100644 --- a/firka/lib/helpers/settings.dart +++ b/firka/lib/helpers/settings.dart @@ -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); diff --git a/firka/lib/helpers/watch_sync_helper.dart b/firka/lib/helpers/watch_sync_helper.dart index 2024df57..8e767a5a 100644 --- a/firka/lib/helpers/watch_sync_helper.dart +++ b/firka/lib/helpers/watch_sync_helper.dart @@ -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; diff --git a/firka/lib/main.dart b/firka/lib/main.dart index 5410dc15..7014bed1 100644 --- a/firka/lib/main.dart +++ b/firka/lib/main.dart @@ -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();