Skip to content

Commit bc7d9df

Browse files
Add backup and restore functionality with localization support
- Introduced `SajiloBackup` struct for exporting and importing user preferences and day plans. - Implemented `exportBackup` and `importBackup` methods in `AppModel` to handle backup operations. - Added `SajiloBackupDocument` for file handling of backup data in JSON format. - Enhanced `SettingsView` with UI elements for exporting and importing backups, including alerts for user feedback. - Updated localization files to include new strings related to backup functionality in both English and Nepali. - Added unit tests for `SajiloBackup` to ensure data integrity during encoding and decoding processes. - Integrated a mini player in `DashboardView` for improved radio playback experience.
1 parent 5ceef84 commit bc7d9df

10 files changed

Lines changed: 364 additions & 0 deletions

File tree

Sources/SajiloApp/Core/Foundation/AppLanguage.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,12 @@ enum L10n {
3232
static let languageEnglish = LocalizedStringResource("language.english", bundle: .sajiloResources)
3333
static let languageNepali = LocalizedStringResource("language.nepali", bundle: .sajiloResources)
3434
static let settings = LocalizedStringResource("screen.settings", bundle: .sajiloResources)
35+
static let backup = LocalizedStringResource("settings.backup", bundle: .sajiloResources)
36+
static let exportData = LocalizedStringResource("settings.export-data", bundle: .sajiloResources)
37+
static let importData = LocalizedStringResource("settings.import-data", bundle: .sajiloResources)
38+
static let backupNote = LocalizedStringResource("settings.backup-note", bundle: .sajiloResources)
39+
static let backupImported = LocalizedStringResource("settings.backup-imported", bundle: .sajiloResources)
40+
static let ok = LocalizedStringResource("action.ok", bundle: .sajiloResources)
3541
static let back = LocalizedStringResource("action.back", bundle: .sajiloResources)
3642
static let festivals = LocalizedStringResource("action.festivals", bundle: .sajiloResources)
3743
static let convert = LocalizedStringResource("action.convert", bundle: .sajiloResources)
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import Foundation
2+
3+
/// A portable, user-owned snapshot of Sajilo's personal data and preferences.
4+
/// Live feeds and caches are intentionally excluded: they are recreated from
5+
/// their public sources, while plans and choices cannot be reconstructed.
6+
struct SajiloBackup: Codable, Equatable, Sendable {
7+
static let currentVersion = 1
8+
9+
struct Preferences: Codable, Equatable, Sendable {
10+
var menuBarFormat: String
11+
var customMenuBarShowsFlag: Bool
12+
var customMenuBarShowsYear: Bool
13+
var appLanguage: String
14+
var numeralStyle: String
15+
var weatherEnabled: Bool
16+
var forexEnabled: Bool
17+
var newsEnabled: Bool
18+
var bazarEnabled: Bool
19+
var rashifalEnabled: Bool
20+
var radioEnabled: Bool
21+
var weatherLocation: String
22+
var forexFavourites: [String]
23+
var vegetableFavourites: [String]
24+
var selectedRashi: String?
25+
var showsDockIcon: Bool
26+
var notifyHolidayEve: Bool
27+
var notifyFestivalEve: Bool
28+
}
29+
30+
let formatVersion: Int
31+
let exportedAt: Date
32+
let preferences: Preferences
33+
let dayPlans: [DayPlan]
34+
35+
init(preferences: Preferences, dayPlans: [DayPlan], exportedAt: Date = .now) {
36+
formatVersion = Self.currentVersion
37+
self.exportedAt = exportedAt
38+
self.preferences = preferences
39+
self.dayPlans = dayPlans
40+
}
41+
42+
func encoded() throws -> Data {
43+
let encoder = JSONEncoder()
44+
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
45+
encoder.dateEncodingStrategy = .iso8601
46+
return try encoder.encode(self)
47+
}
48+
49+
static func decode(_ data: Data) throws -> Self {
50+
let decoder = JSONDecoder()
51+
decoder.dateDecodingStrategy = .iso8601
52+
let backup = try decoder.decode(Self.self, from: data)
53+
guard backup.formatVersion == currentVersion else {
54+
throw BackupError.unsupportedVersion(backup.formatVersion)
55+
}
56+
return backup
57+
}
58+
59+
enum BackupError: LocalizedError, Equatable {
60+
case unsupportedVersion(Int)
61+
62+
var errorDescription: String? {
63+
switch self {
64+
case let .unsupportedVersion(version):
65+
"This backup uses an unsupported Sajilo format (version \(version))."
66+
}
67+
}
68+
}
69+
}

Sources/SajiloApp/Features/AppModel.swift

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -707,6 +707,68 @@ final class AppModel {
707707
Task { [weak self] in await self?.rescheduleNotifications() }
708708
}
709709

710+
// MARK: - Backup
711+
712+
func exportBackup() throws -> Data {
713+
try SajiloBackup(
714+
preferences: .init(
715+
menuBarFormat: selectedMenuBarFormat.rawValue,
716+
customMenuBarShowsFlag: customMenuBarShowsFlag,
717+
customMenuBarShowsYear: customMenuBarShowsYear,
718+
appLanguage: appLanguage.rawValue,
719+
numeralStyle: numeralStyle.rawValue,
720+
weatherEnabled: isWeatherEnabled,
721+
forexEnabled: isForexEnabled,
722+
newsEnabled: isNewsEnabled,
723+
bazarEnabled: isBazarEnabled,
724+
rashifalEnabled: isRashifalEnabled,
725+
radioEnabled: isRadioEnabled,
726+
weatherLocation: selectedWeatherLocation.rawValue,
727+
forexFavourites: forexFavourites,
728+
vegetableFavourites: vegetableFavourites,
729+
selectedRashi: selectedRashi?.rawValue,
730+
showsDockIcon: showsDockIcon,
731+
notifyHolidayEve: notificationOptions.eveOfPublicHoliday,
732+
notifyFestivalEve: notificationOptions.eveOfFestival
733+
),
734+
dayPlans: dayPlans
735+
).encoded()
736+
}
737+
738+
/// Imports preferences and adds plans that are not already present. An
739+
/// import never deletes local plans or overwrites one with the same ID.
740+
func importBackup(_ data: Data) throws {
741+
let backup = try SajiloBackup.decode(data)
742+
let preferences = backup.preferences
743+
744+
selectedMenuBarFormat = MenuBarFormat(rawValue: preferences.menuBarFormat) ?? selectedMenuBarFormat
745+
customMenuBarShowsFlag = preferences.customMenuBarShowsFlag
746+
customMenuBarShowsYear = preferences.customMenuBarShowsYear
747+
appLanguage = AppLanguage(rawValue: preferences.appLanguage) ?? appLanguage
748+
numeralStyle = NumeralStyle(rawValue: preferences.numeralStyle) ?? numeralStyle
749+
isWeatherEnabled = preferences.weatherEnabled
750+
isForexEnabled = preferences.forexEnabled
751+
isNewsEnabled = preferences.newsEnabled
752+
isBazarEnabled = preferences.bazarEnabled
753+
isRashifalEnabled = preferences.rashifalEnabled
754+
isRadioEnabled = preferences.radioEnabled
755+
selectedWeatherLocation = WeatherLocation(rawValue: preferences.weatherLocation) ?? selectedWeatherLocation
756+
forexFavourites = preferences.forexFavourites
757+
vegetableFavourites = preferences.vegetableFavourites
758+
selectedRashi = preferences.selectedRashi.flatMap(RashiSign.init(rawValue:))
759+
showsDockIcon = preferences.showsDockIcon
760+
notificationOptions = .init(
761+
eveOfPublicHoliday: preferences.notifyHolidayEve,
762+
eveOfFestival: preferences.notifyFestivalEve
763+
)
764+
765+
let existingIDs = Set(dayPlans.map(\.id))
766+
dayPlans.append(contentsOf: backup.dayPlans.filter { !existingIDs.contains($0.id) })
767+
dayPlanStore.save(dayPlans)
768+
applyActivationPolicy()
769+
Task { [weak self] in await self?.rescheduleNotifications() }
770+
}
771+
710772
// MARK: - Notifications
711773

712774
/// Reads the current permission without prompting, so Settings can explain

Sources/SajiloApp/Features/Dashboard/DashboardView.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,15 @@ struct DashboardView: View {
7676
.modifier(RouteLayer(isActive: route == .tools, edge: 1, reduceMotion: reduceMotion))
7777
}
7878

79+
if let station = model.radioPlayer.currentStation, model.radioPlayer.isPlaying {
80+
RadioMiniPlayer(
81+
station: station,
82+
isResolving: model.radioPlayer.isResolving,
83+
openRadio: { navigate(to: .radio) },
84+
togglePlayback: { Task { await model.radioPlayer.toggle(station) } }
85+
)
86+
}
87+
7988
actionBar
8089
}
8190
.environment(\.numeralStyle, model.numeralStyle)

Sources/SajiloApp/Features/Radio/RadioView.swift

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,50 @@ private struct NowPlayingCard: View {
157157
}
158158
}
159159

160+
/// Remains visible above Sajilo's navigation while a station is playing, so
161+
/// leaving the Radio screen never makes audio feel detached from the app.
162+
struct RadioMiniPlayer: View {
163+
let station: RadioStation
164+
let isResolving: Bool
165+
let openRadio: () -> Void
166+
let togglePlayback: () -> Void
167+
168+
var body: some View {
169+
HStack(spacing: Theme.Space.s) {
170+
EqualizerView(isPlaying: true)
171+
172+
Button(action: openRadio) {
173+
VStack(alignment: .leading, spacing: 1) {
174+
Text(L10n.radio)
175+
.font(.caption2)
176+
.foregroundStyle(.secondary)
177+
Text(verbatim: station.name)
178+
.font(.caption.weight(.semibold))
179+
.lineLimit(1)
180+
}
181+
.frame(maxWidth: .infinity, alignment: .leading)
182+
.contentShape(.rect)
183+
}
184+
.buttonStyle(.plain)
185+
.accessibilityLabel("Now playing \(station.name)")
186+
.accessibilityHint("Opens radio")
187+
188+
Button(action: togglePlayback) {
189+
Image(systemName: isResolving ? "arrow.clockwise" : "pause.fill")
190+
}
191+
.buttonStyle(IconButtonStyle())
192+
.disabled(isResolving)
193+
.accessibilityLabel("Pause radio")
194+
}
195+
.padding(.horizontal, Theme.Space.m)
196+
.padding(.vertical, Theme.Space.xs)
197+
.background(Theme.Palette.brand.opacity(0.10))
198+
.overlay(alignment: .top) {
199+
Divider().opacity(0.45)
200+
}
201+
}
202+
}
203+
160204
private struct StationRow: View {
161205
let station: RadioStation
162206
let isCurrent: Bool
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import UniformTypeIdentifiers
2+
import SwiftUI
3+
4+
struct SajiloBackupDocument: FileDocument {
5+
static let readableContentTypes: [UTType] = [.json]
6+
7+
let data: Data
8+
9+
init(data: Data) {
10+
self.data = data
11+
}
12+
13+
init(configuration: ReadConfiguration) throws {
14+
guard let data = configuration.file.regularFileContents else {
15+
throw CocoaError(.fileReadCorruptFile)
16+
}
17+
self.data = data
18+
}
19+
20+
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
21+
FileWrapper(regularFileWithContents: data)
22+
}
23+
}

Sources/SajiloApp/Features/Settings/SettingsView.swift

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ struct SettingsView: View {
1111
@Environment(\.appUpdater) private var appUpdater
1212
@Bindable var model: AppModel
1313
let onBack: () -> Void
14+
@State private var backupDocument: SajiloBackupDocument?
15+
@State private var isExportingBackup = false
16+
@State private var isImportingBackup = false
17+
@State private var backupMessage: String?
1418

1519
var body: some View {
1620
VStack(spacing: 0) {
@@ -57,6 +61,26 @@ struct SettingsView: View {
5761
.disabled(appUpdater == nil)
5862
}
5963

64+
SettingsSection(L10n.backup) {
65+
Button(L10n.exportData, systemImage: "square.and.arrow.up") {
66+
do {
67+
backupDocument = SajiloBackupDocument(data: try model.exportBackup())
68+
isExportingBackup = true
69+
} catch {
70+
backupMessage = error.localizedDescription
71+
}
72+
}
73+
74+
Button(L10n.importData, systemImage: "square.and.arrow.down") {
75+
isImportingBackup = true
76+
}
77+
78+
Text(L10n.backupNote)
79+
.font(.caption2)
80+
.foregroundStyle(.secondary)
81+
.fixedSize(horizontal: false, vertical: true)
82+
}
83+
6084
SettingsSection(L10n.numerals) {
6185
Picker(L10n.numerals, selection: $model.numeralStyle) {
6286
ForEach(NumeralStyle.allCases) { style in
@@ -146,6 +170,41 @@ struct SettingsView: View {
146170
// Reads the current permission; it never prompts, so opening Settings
147171
// cannot trigger a system dialog.
148172
.task { await model.refreshNotificationAuthorization() }
173+
.fileExporter(
174+
isPresented: $isExportingBackup,
175+
document: backupDocument,
176+
contentType: .json,
177+
defaultFilename: "Sajilo-backup"
178+
) { result in
179+
if case let .failure(error) = result {
180+
backupMessage = error.localizedDescription
181+
}
182+
}
183+
.fileImporter(
184+
isPresented: $isImportingBackup,
185+
allowedContentTypes: [.json],
186+
allowsMultipleSelection: false
187+
) { result in
188+
do {
189+
let url = try result.get().first ?? { throw CocoaError(.fileReadNoSuchFile) }()
190+
guard url.startAccessingSecurityScopedResource() else {
191+
throw CocoaError(.fileReadNoPermission)
192+
}
193+
defer { url.stopAccessingSecurityScopedResource() }
194+
try model.importBackup(Data(contentsOf: url))
195+
backupMessage = String(localized: L10n.backupImported)
196+
} catch {
197+
backupMessage = error.localizedDescription
198+
}
199+
}
200+
.alert(L10n.backup, isPresented: Binding(
201+
get: { backupMessage != nil },
202+
set: { if !$0 { backupMessage = nil } }
203+
)) {
204+
Button(L10n.ok) { backupMessage = nil }
205+
} message: {
206+
Text(verbatim: backupMessage ?? "")
207+
}
149208
}
150209

151210
/// A reminder switched on but denied at the system level would otherwise

Sources/SajiloApp/Resources/en.lproj/Localizable.strings

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,11 @@
9999
"screen.settings" = "Settings";
100100
"screen.upcoming" = "Upcoming events";
101101
"settings.bazar" = "Gold, silver and fuel";
102+
"settings.backup" = "Backup";
103+
"settings.backup-imported" = "Sajilo data imported.";
104+
"settings.backup-note" = "Backs up your personal dates and Sajilo settings. Live prices and news are fetched again.";
105+
"settings.export-data" = "Export Sajilo data…";
106+
"settings.import-data" = "Import Sajilo data…";
102107
"settings.bazar-source" = "Rates source";
103108
"settings.calendar-range" = "Calendar";
104109
"settings.city" = "City";

Sources/SajiloApp/Resources/ne.lproj/Localizable.strings

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,11 @@
9999
"screen.settings" = "सेटिङ";
100100
"screen.upcoming" = "आगामी कार्यक्रमहरू";
101101
"settings.bazar" = "सुन, चाँदी र इन्धन";
102+
"settings.backup" = "ब्याकअप";
103+
"settings.backup-imported" = "Sajilo डेटा आयात भयो।";
104+
"settings.backup-note" = "तपाईंका व्यक्तिगत मिति र Sajilo सेटिङको ब्याकअप हुन्छ। मूल्य र समाचार फेरि ल्याइन्छ।";
105+
"settings.export-data" = "Sajilo डेटा निर्यात गर्नुहोस्…";
106+
"settings.import-data" = "Sajilo डेटा आयात गर्नुहोस्…";
102107
"settings.bazar-source" = "दरको स्रोत";
103108
"settings.calendar-range" = "पात्रो";
104109
"settings.city" = "सहर";

0 commit comments

Comments
 (0)