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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -505,7 +505,7 @@ class DataStore {
|
||||
let elapsed = Date().timeIntervalSince(lastUpdated)
|
||||
|
||||
if elapsed < 60 {
|
||||
return nil
|
||||
return "time_now".localized
|
||||
}
|
||||
|
||||
// Minutes
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user