docs: Update README for v2.0 release and fix git remote URL

- Comprehensive README update documenting v2.0 architectural changes
- Updated git remote to ssh://rockvilleav@git.rockvilletollandsda.church:10443/RTSDA/RTSDA-iOS.git
- Documented unified ChurchService and 60% code reduction
- Added new features: Home Feed, responsive reading, enhanced UI
- Corrected license information (GPL v3 with church content copyright)
- Updated build instructions and technical stack details
This commit is contained in:
RTSDA 2025-08-16 18:41:51 -04:00
parent 61e0ad8d5b
commit 00679f927c
79 changed files with 14242 additions and 5262 deletions

149
CHURCH_SERVICE_MIGRATION.md Normal file
View file

@ -0,0 +1,149 @@
# ChurchService Migration Guide
The new `ChurchService` replaces all legacy networking services and uses the church_core Rust library for consistent, performant API calls.
## Migration Overview
**Replace these services:**
- `PocketBaseService`
- `BulletinService`
- `BibleService`
- `ConfigService`
**With this unified service:**
- `ChurchService`
## Quick Migration
### Before (Old Services)
```swift
// Config
let config = try await PocketBaseService.shared.fetchConfig()
// Events
let events = try await PocketBaseService.shared.fetchEvents()
// Bulletins
let bulletins = try await BulletinService.shared.getBulletins()
let latest = try await BulletinService.shared.getLatestBulletin()
// Bible Verses
let verse = try await BibleService.shared.getRandomVerse()
let specificVerse = try await BibleService.shared.getVerse(reference: "John 3:16")
```
### After (ChurchService)
```swift
// Config
let config = try await ChurchService.shared.fetchConfig()
// Events
let events = try await ChurchService.shared.fetchEvents()
// Bulletins
let bulletins = try await ChurchService.shared.getBulletins()
let latest = try await ChurchService.shared.getLatestBulletin()
// Bible Verses
let verse = try await ChurchService.shared.getRandomVerse()
let specificVerse = try await ChurchService.shared.getVerse(reference: "John 3:16")
// Contact
let success = try await ChurchService.shared.submitContact(
name: "John Doe",
email: "john@example.com",
message: "Hello"
)
// Sermons
let sermons = try await ChurchService.shared.fetchSermons()
```
## Benefits of Migration
### ✅ **Code Reduction**
- **Before:** ~500+ lines across 4 services
- **After:** ~220 lines in 1 service
- **Reduction:** 60%+ less duplicate code
### ✅ **Performance**
- Uses optimized Rust networking (reqwest)
- Built-in memory caching
- Faster JSON parsing
- Better error handling
### ✅ **Consistency**
- Same API used by website and Beacon
- Consistent data models
- Unified error handling
- Single source of truth
### ✅ **Maintenance**
- One service to maintain
- Automatic updates from church_core
- Fewer bugs and edge cases
- Better testing coverage
## Implementation Details
### Required Files
1. **Add to project:** `church_core.swift` (UniFFI bindings)
2. **Add to project:** `libchurch_core.dylib` (Rust library)
3. **Replace with:** `ChurchService.swift` (Unified service)
### Xcode Integration
1. Add `church_core.swift` to your Xcode project
2. Add `libchurch_core.dylib` to your project and link it
3. Replace service imports with `ChurchService.shared`
### Backwards Compatibility
The new service maintains the same method signatures as the old services, so migration is a simple find-and-replace:
```swift
// Find: PocketBaseService.shared
// Replace: ChurchService.shared
// Find: BulletinService.shared
// Replace: ChurchService.shared
// Find: BibleService.shared
// Replace: ChurchService.shared
```
## Testing
```swift
// Test the new service
Task {
do {
let config = try await ChurchService.shared.fetchConfig()
print("Church: \(config.churchName)")
let verse = try await ChurchService.shared.getRandomVerse()
print("Verse: \(verse.reference) - \(verse.verse)")
let bulletins = try await ChurchService.shared.getBulletins()
print("Bulletins: \(bulletins.count)")
} catch {
print("Error: \(error)")
}
}
```
## Migration Checklist
- [ ] Add `church_core.swift` to Xcode project
- [ ] Add `libchurch_core.dylib` to project and link
- [ ] Add `ChurchService.swift` to project
- [ ] Update Sermon model to be Codable (already done)
- [ ] Replace service imports in Views/ViewModels
- [ ] Test all functionality works
- [ ] Remove old service files (optional)
- [ ] Update any custom networking code
## Next Steps
Once migration is complete, you can:
1. Delete the old service files
2. Clean up any unused networking utilities
3. Enjoy simplified, faster networking! 🚀

View file

@ -0,0 +1,66 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>AvailableLibraries</key>
<array>
<dict>
<key>BinaryPath</key>
<string>libchurch_core_device.a</string>
<key>HeadersPath</key>
<string>Headers</string>
<key>LibraryIdentifier</key>
<string>ios-arm64</string>
<key>LibraryPath</key>
<string>libchurch_core_device.a</string>
<key>SupportedArchitectures</key>
<array>
<string>arm64</string>
</array>
<key>SupportedPlatform</key>
<string>ios</string>
</dict>
<dict>
<key>BinaryPath</key>
<string>libchurch_core_mac_catalyst.a</string>
<key>HeadersPath</key>
<string>Headers</string>
<key>LibraryIdentifier</key>
<string>ios-arm64_x86_64-maccatalyst</string>
<key>LibraryPath</key>
<string>libchurch_core_mac_catalyst.a</string>
<key>SupportedArchitectures</key>
<array>
<string>arm64</string>
<string>x86_64</string>
</array>
<key>SupportedPlatform</key>
<string>ios</string>
<key>SupportedPlatformVariant</key>
<string>maccatalyst</string>
</dict>
<dict>
<key>BinaryPath</key>
<string>libchurch_core_sim.a</string>
<key>HeadersPath</key>
<string>Headers</string>
<key>LibraryIdentifier</key>
<string>ios-arm64-simulator</string>
<key>LibraryPath</key>
<string>libchurch_core_sim.a</string>
<key>SupportedArchitectures</key>
<array>
<string>arm64</string>
</array>
<key>SupportedPlatform</key>
<string>ios</string>
<key>SupportedPlatformVariant</key>
<string>simulator</string>
</dict>
</array>
<key>CFBundlePackageType</key>
<string>XFWK</string>
<key>XCFrameworkFormatVersion</key>
<string>1.0</string>
</dict>
</plist>

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,914 @@
// This file was autogenerated by some hot garbage in the `uniffi` crate.
// Trust me, you don't want to mess with it!
#pragma once
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
// The following structs are used to implement the lowest level
// of the FFI, and thus useful to multiple uniffied crates.
// We ensure they are declared exactly once, with a header guard, UNIFFI_SHARED_H.
#ifdef UNIFFI_SHARED_H
// We also try to prevent mixing versions of shared uniffi header structs.
// If you add anything to the #else block, you must increment the version suffix in UNIFFI_SHARED_HEADER_V4
#ifndef UNIFFI_SHARED_HEADER_V4
#error Combining helper code from multiple versions of uniffi is not supported
#endif // ndef UNIFFI_SHARED_HEADER_V4
#else
#define UNIFFI_SHARED_H
#define UNIFFI_SHARED_HEADER_V4
// ⚠️ Attention: If you change this #else block (ending in `#endif // def UNIFFI_SHARED_H`) you *must* ⚠️
// ⚠️ increment the version suffix in all instances of UNIFFI_SHARED_HEADER_V4 in this file. ⚠️
typedef struct RustBuffer
{
uint64_t capacity;
uint64_t len;
uint8_t *_Nullable data;
} RustBuffer;
typedef struct ForeignBytes
{
int32_t len;
const uint8_t *_Nullable data;
} ForeignBytes;
// Error definitions
typedef struct RustCallStatus {
int8_t code;
RustBuffer errorBuf;
} RustCallStatus;
// ⚠️ Attention: If you change this #else block (ending in `#endif // def UNIFFI_SHARED_H`) you *must* ⚠️
// ⚠️ increment the version suffix in all instances of UNIFFI_SHARED_HEADER_V4 in this file. ⚠️
#endif // def UNIFFI_SHARED_H
#ifndef UNIFFI_FFIDEF_RUST_FUTURE_CONTINUATION_CALLBACK
#define UNIFFI_FFIDEF_RUST_FUTURE_CONTINUATION_CALLBACK
typedef void (*UniffiRustFutureContinuationCallback)(uint64_t, int8_t
);
#endif
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_FREE
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_FREE
typedef void (*UniffiForeignFutureFree)(uint64_t
);
#endif
#ifndef UNIFFI_FFIDEF_CALLBACK_INTERFACE_FREE
#define UNIFFI_FFIDEF_CALLBACK_INTERFACE_FREE
typedef void (*UniffiCallbackInterfaceFree)(uint64_t
);
#endif
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE
#define UNIFFI_FFIDEF_FOREIGN_FUTURE
typedef struct UniffiForeignFuture {
uint64_t handle;
UniffiForeignFutureFree _Nonnull free;
} UniffiForeignFuture;
#endif
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_U8
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_U8
typedef struct UniffiForeignFutureStructU8 {
uint8_t returnValue;
RustCallStatus callStatus;
} UniffiForeignFutureStructU8;
#endif
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_U8
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_U8
typedef void (*UniffiForeignFutureCompleteU8)(uint64_t, UniffiForeignFutureStructU8
);
#endif
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_I8
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_I8
typedef struct UniffiForeignFutureStructI8 {
int8_t returnValue;
RustCallStatus callStatus;
} UniffiForeignFutureStructI8;
#endif
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_I8
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_I8
typedef void (*UniffiForeignFutureCompleteI8)(uint64_t, UniffiForeignFutureStructI8
);
#endif
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_U16
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_U16
typedef struct UniffiForeignFutureStructU16 {
uint16_t returnValue;
RustCallStatus callStatus;
} UniffiForeignFutureStructU16;
#endif
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_U16
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_U16
typedef void (*UniffiForeignFutureCompleteU16)(uint64_t, UniffiForeignFutureStructU16
);
#endif
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_I16
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_I16
typedef struct UniffiForeignFutureStructI16 {
int16_t returnValue;
RustCallStatus callStatus;
} UniffiForeignFutureStructI16;
#endif
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_I16
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_I16
typedef void (*UniffiForeignFutureCompleteI16)(uint64_t, UniffiForeignFutureStructI16
);
#endif
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_U32
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_U32
typedef struct UniffiForeignFutureStructU32 {
uint32_t returnValue;
RustCallStatus callStatus;
} UniffiForeignFutureStructU32;
#endif
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_U32
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_U32
typedef void (*UniffiForeignFutureCompleteU32)(uint64_t, UniffiForeignFutureStructU32
);
#endif
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_I32
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_I32
typedef struct UniffiForeignFutureStructI32 {
int32_t returnValue;
RustCallStatus callStatus;
} UniffiForeignFutureStructI32;
#endif
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_I32
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_I32
typedef void (*UniffiForeignFutureCompleteI32)(uint64_t, UniffiForeignFutureStructI32
);
#endif
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_U64
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_U64
typedef struct UniffiForeignFutureStructU64 {
uint64_t returnValue;
RustCallStatus callStatus;
} UniffiForeignFutureStructU64;
#endif
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_U64
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_U64
typedef void (*UniffiForeignFutureCompleteU64)(uint64_t, UniffiForeignFutureStructU64
);
#endif
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_I64
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_I64
typedef struct UniffiForeignFutureStructI64 {
int64_t returnValue;
RustCallStatus callStatus;
} UniffiForeignFutureStructI64;
#endif
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_I64
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_I64
typedef void (*UniffiForeignFutureCompleteI64)(uint64_t, UniffiForeignFutureStructI64
);
#endif
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_F32
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_F32
typedef struct UniffiForeignFutureStructF32 {
float returnValue;
RustCallStatus callStatus;
} UniffiForeignFutureStructF32;
#endif
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_F32
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_F32
typedef void (*UniffiForeignFutureCompleteF32)(uint64_t, UniffiForeignFutureStructF32
);
#endif
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_F64
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_F64
typedef struct UniffiForeignFutureStructF64 {
double returnValue;
RustCallStatus callStatus;
} UniffiForeignFutureStructF64;
#endif
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_F64
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_F64
typedef void (*UniffiForeignFutureCompleteF64)(uint64_t, UniffiForeignFutureStructF64
);
#endif
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_POINTER
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_POINTER
typedef struct UniffiForeignFutureStructPointer {
void*_Nonnull returnValue;
RustCallStatus callStatus;
} UniffiForeignFutureStructPointer;
#endif
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_POINTER
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_POINTER
typedef void (*UniffiForeignFutureCompletePointer)(uint64_t, UniffiForeignFutureStructPointer
);
#endif
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_RUST_BUFFER
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_RUST_BUFFER
typedef struct UniffiForeignFutureStructRustBuffer {
RustBuffer returnValue;
RustCallStatus callStatus;
} UniffiForeignFutureStructRustBuffer;
#endif
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_RUST_BUFFER
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_RUST_BUFFER
typedef void (*UniffiForeignFutureCompleteRustBuffer)(uint64_t, UniffiForeignFutureStructRustBuffer
);
#endif
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_VOID
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_STRUCT_VOID
typedef struct UniffiForeignFutureStructVoid {
RustCallStatus callStatus;
} UniffiForeignFutureStructVoid;
#endif
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_VOID
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_VOID
typedef void (*UniffiForeignFutureCompleteVoid)(uint64_t, UniffiForeignFutureStructVoid
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_FN_FUNC_CREATE_SERMON_SHARE_ITEMS_JSON
#define UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_FN_FUNC_CREATE_SERMON_SHARE_ITEMS_JSON
RustBuffer uniffi_church_core_fn_func_create_sermon_share_items_json(RustBuffer title, RustBuffer speaker, RustBuffer video_url, RustBuffer audio_url, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_FN_FUNC_DEVICE_SUPPORTS_AV1
#define UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_FN_FUNC_DEVICE_SUPPORTS_AV1
int8_t uniffi_church_core_fn_func_device_supports_av1(RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_FN_FUNC_EXTRACT_SCRIPTURE_REFERENCES_STRING
#define UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_FN_FUNC_EXTRACT_SCRIPTURE_REFERENCES_STRING
RustBuffer uniffi_church_core_fn_func_extract_scripture_references_string(RustBuffer scripture_text, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_FN_FUNC_FETCH_BIBLE_VERSE_JSON
#define UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_FN_FUNC_FETCH_BIBLE_VERSE_JSON
RustBuffer uniffi_church_core_fn_func_fetch_bible_verse_json(RustBuffer query, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_FN_FUNC_FETCH_BULLETINS_JSON
#define UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_FN_FUNC_FETCH_BULLETINS_JSON
RustBuffer uniffi_church_core_fn_func_fetch_bulletins_json(RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_FN_FUNC_FETCH_CACHED_IMAGE_BASE64
#define UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_FN_FUNC_FETCH_CACHED_IMAGE_BASE64
RustBuffer uniffi_church_core_fn_func_fetch_cached_image_base64(RustBuffer url, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_FN_FUNC_FETCH_CONFIG_JSON
#define UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_FN_FUNC_FETCH_CONFIG_JSON
RustBuffer uniffi_church_core_fn_func_fetch_config_json(RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_FN_FUNC_FETCH_CURRENT_BULLETIN_JSON
#define UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_FN_FUNC_FETCH_CURRENT_BULLETIN_JSON
RustBuffer uniffi_church_core_fn_func_fetch_current_bulletin_json(RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_FN_FUNC_FETCH_EVENTS_JSON
#define UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_FN_FUNC_FETCH_EVENTS_JSON
RustBuffer uniffi_church_core_fn_func_fetch_events_json(RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_FN_FUNC_FETCH_FEATURED_EVENTS_JSON
#define UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_FN_FUNC_FETCH_FEATURED_EVENTS_JSON
RustBuffer uniffi_church_core_fn_func_fetch_featured_events_json(RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_FN_FUNC_FETCH_LIVE_STREAM_JSON
#define UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_FN_FUNC_FETCH_LIVE_STREAM_JSON
RustBuffer uniffi_church_core_fn_func_fetch_live_stream_json(RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_FN_FUNC_FETCH_LIVESTREAM_ARCHIVE_JSON
#define UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_FN_FUNC_FETCH_LIVESTREAM_ARCHIVE_JSON
RustBuffer uniffi_church_core_fn_func_fetch_livestream_archive_json(RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_FN_FUNC_FETCH_RANDOM_BIBLE_VERSE_JSON
#define UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_FN_FUNC_FETCH_RANDOM_BIBLE_VERSE_JSON
RustBuffer uniffi_church_core_fn_func_fetch_random_bible_verse_json(RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_FN_FUNC_FETCH_SCRIPTURE_VERSES_FOR_SERMON_JSON
#define UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_FN_FUNC_FETCH_SCRIPTURE_VERSES_FOR_SERMON_JSON
RustBuffer uniffi_church_core_fn_func_fetch_scripture_verses_for_sermon_json(RustBuffer sermon_id, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_FN_FUNC_FETCH_SERMONS_JSON
#define UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_FN_FUNC_FETCH_SERMONS_JSON
RustBuffer uniffi_church_core_fn_func_fetch_sermons_json(RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_FN_FUNC_FETCH_STREAM_STATUS_JSON
#define UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_FN_FUNC_FETCH_STREAM_STATUS_JSON
RustBuffer uniffi_church_core_fn_func_fetch_stream_status_json(RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_FN_FUNC_FILTER_SERMONS_BY_MEDIA_TYPE
#define UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_FN_FUNC_FILTER_SERMONS_BY_MEDIA_TYPE
RustBuffer uniffi_church_core_fn_func_filter_sermons_by_media_type(RustBuffer sermons_json, RustBuffer media_type_str, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_FN_FUNC_FORMAT_EVENT_FOR_DISPLAY_JSON
#define UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_FN_FUNC_FORMAT_EVENT_FOR_DISPLAY_JSON
RustBuffer uniffi_church_core_fn_func_format_event_for_display_json(RustBuffer event_json, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_FN_FUNC_FORMAT_SCRIPTURE_TEXT_JSON
#define UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_FN_FUNC_FORMAT_SCRIPTURE_TEXT_JSON
RustBuffer uniffi_church_core_fn_func_format_scripture_text_json(RustBuffer scripture_text, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_FN_FUNC_FORMAT_TIME_RANGE_STRING
#define UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_FN_FUNC_FORMAT_TIME_RANGE_STRING
RustBuffer uniffi_church_core_fn_func_format_time_range_string(RustBuffer start_time, RustBuffer end_time, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_FN_FUNC_GENERATE_HOME_FEED_JSON
#define UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_FN_FUNC_GENERATE_HOME_FEED_JSON
RustBuffer uniffi_church_core_fn_func_generate_home_feed_json(RustBuffer events_json, RustBuffer sermons_json, RustBuffer bulletins_json, RustBuffer verse_json, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_FN_FUNC_GET_AV1_STREAMING_URL
#define UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_FN_FUNC_GET_AV1_STREAMING_URL
RustBuffer uniffi_church_core_fn_func_get_av1_streaming_url(RustBuffer media_id, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_FN_FUNC_GET_HLS_STREAMING_URL
#define UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_FN_FUNC_GET_HLS_STREAMING_URL
RustBuffer uniffi_church_core_fn_func_get_hls_streaming_url(RustBuffer media_id, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_FN_FUNC_GET_MEDIA_TYPE_DISPLAY_NAME
#define UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_FN_FUNC_GET_MEDIA_TYPE_DISPLAY_NAME
RustBuffer uniffi_church_core_fn_func_get_media_type_display_name(RustBuffer media_type_str, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_FN_FUNC_GET_MEDIA_TYPE_ICON
#define UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_FN_FUNC_GET_MEDIA_TYPE_ICON
RustBuffer uniffi_church_core_fn_func_get_media_type_icon(RustBuffer media_type_str, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_FN_FUNC_GET_OPTIMAL_STREAMING_URL
#define UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_FN_FUNC_GET_OPTIMAL_STREAMING_URL
RustBuffer uniffi_church_core_fn_func_get_optimal_streaming_url(RustBuffer media_id, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_FN_FUNC_IS_MULTI_DAY_EVENT_CHECK
#define UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_FN_FUNC_IS_MULTI_DAY_EVENT_CHECK
int8_t uniffi_church_core_fn_func_is_multi_day_event_check(RustBuffer date, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_FN_FUNC_SUBMIT_CONTACT_JSON
#define UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_FN_FUNC_SUBMIT_CONTACT_JSON
RustBuffer uniffi_church_core_fn_func_submit_contact_json(RustBuffer name, RustBuffer email, RustBuffer message, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_FN_FUNC_SUBMIT_CONTACT_V2_JSON
#define UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_FN_FUNC_SUBMIT_CONTACT_V2_JSON
RustBuffer uniffi_church_core_fn_func_submit_contact_v2_json(RustBuffer name, RustBuffer email, RustBuffer subject, RustBuffer message, RustBuffer phone, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_FN_FUNC_SUBMIT_CONTACT_V2_JSON_LEGACY
#define UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_FN_FUNC_SUBMIT_CONTACT_V2_JSON_LEGACY
RustBuffer uniffi_church_core_fn_func_submit_contact_v2_json_legacy(RustBuffer first_name, RustBuffer last_name, RustBuffer email, RustBuffer subject, RustBuffer message, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_FN_FUNC_VALIDATE_CONTACT_FORM_JSON
#define UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_FN_FUNC_VALIDATE_CONTACT_FORM_JSON
RustBuffer uniffi_church_core_fn_func_validate_contact_form_json(RustBuffer form_json, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_FN_FUNC_VALIDATE_EMAIL_ADDRESS
#define UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_FN_FUNC_VALIDATE_EMAIL_ADDRESS
int8_t uniffi_church_core_fn_func_validate_email_address(RustBuffer email, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_FN_FUNC_VALIDATE_PHONE_NUMBER
#define UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_FN_FUNC_VALIDATE_PHONE_NUMBER
int8_t uniffi_church_core_fn_func_validate_phone_number(RustBuffer phone, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUSTBUFFER_ALLOC
#define UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUSTBUFFER_ALLOC
RustBuffer ffi_church_core_rustbuffer_alloc(uint64_t size, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUSTBUFFER_FROM_BYTES
#define UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUSTBUFFER_FROM_BYTES
RustBuffer ffi_church_core_rustbuffer_from_bytes(ForeignBytes bytes, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUSTBUFFER_FREE
#define UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUSTBUFFER_FREE
void ffi_church_core_rustbuffer_free(RustBuffer buf, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUSTBUFFER_RESERVE
#define UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUSTBUFFER_RESERVE
RustBuffer ffi_church_core_rustbuffer_reserve(RustBuffer buf, uint64_t additional, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_POLL_U8
#define UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_POLL_U8
void ffi_church_core_rust_future_poll_u8(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_CANCEL_U8
#define UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_CANCEL_U8
void ffi_church_core_rust_future_cancel_u8(uint64_t handle
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_FREE_U8
#define UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_FREE_U8
void ffi_church_core_rust_future_free_u8(uint64_t handle
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_COMPLETE_U8
#define UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_COMPLETE_U8
uint8_t ffi_church_core_rust_future_complete_u8(uint64_t handle, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_POLL_I8
#define UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_POLL_I8
void ffi_church_core_rust_future_poll_i8(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_CANCEL_I8
#define UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_CANCEL_I8
void ffi_church_core_rust_future_cancel_i8(uint64_t handle
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_FREE_I8
#define UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_FREE_I8
void ffi_church_core_rust_future_free_i8(uint64_t handle
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_COMPLETE_I8
#define UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_COMPLETE_I8
int8_t ffi_church_core_rust_future_complete_i8(uint64_t handle, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_POLL_U16
#define UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_POLL_U16
void ffi_church_core_rust_future_poll_u16(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_CANCEL_U16
#define UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_CANCEL_U16
void ffi_church_core_rust_future_cancel_u16(uint64_t handle
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_FREE_U16
#define UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_FREE_U16
void ffi_church_core_rust_future_free_u16(uint64_t handle
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_COMPLETE_U16
#define UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_COMPLETE_U16
uint16_t ffi_church_core_rust_future_complete_u16(uint64_t handle, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_POLL_I16
#define UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_POLL_I16
void ffi_church_core_rust_future_poll_i16(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_CANCEL_I16
#define UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_CANCEL_I16
void ffi_church_core_rust_future_cancel_i16(uint64_t handle
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_FREE_I16
#define UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_FREE_I16
void ffi_church_core_rust_future_free_i16(uint64_t handle
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_COMPLETE_I16
#define UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_COMPLETE_I16
int16_t ffi_church_core_rust_future_complete_i16(uint64_t handle, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_POLL_U32
#define UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_POLL_U32
void ffi_church_core_rust_future_poll_u32(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_CANCEL_U32
#define UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_CANCEL_U32
void ffi_church_core_rust_future_cancel_u32(uint64_t handle
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_FREE_U32
#define UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_FREE_U32
void ffi_church_core_rust_future_free_u32(uint64_t handle
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_COMPLETE_U32
#define UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_COMPLETE_U32
uint32_t ffi_church_core_rust_future_complete_u32(uint64_t handle, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_POLL_I32
#define UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_POLL_I32
void ffi_church_core_rust_future_poll_i32(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_CANCEL_I32
#define UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_CANCEL_I32
void ffi_church_core_rust_future_cancel_i32(uint64_t handle
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_FREE_I32
#define UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_FREE_I32
void ffi_church_core_rust_future_free_i32(uint64_t handle
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_COMPLETE_I32
#define UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_COMPLETE_I32
int32_t ffi_church_core_rust_future_complete_i32(uint64_t handle, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_POLL_U64
#define UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_POLL_U64
void ffi_church_core_rust_future_poll_u64(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_CANCEL_U64
#define UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_CANCEL_U64
void ffi_church_core_rust_future_cancel_u64(uint64_t handle
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_FREE_U64
#define UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_FREE_U64
void ffi_church_core_rust_future_free_u64(uint64_t handle
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_COMPLETE_U64
#define UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_COMPLETE_U64
uint64_t ffi_church_core_rust_future_complete_u64(uint64_t handle, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_POLL_I64
#define UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_POLL_I64
void ffi_church_core_rust_future_poll_i64(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_CANCEL_I64
#define UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_CANCEL_I64
void ffi_church_core_rust_future_cancel_i64(uint64_t handle
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_FREE_I64
#define UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_FREE_I64
void ffi_church_core_rust_future_free_i64(uint64_t handle
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_COMPLETE_I64
#define UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_COMPLETE_I64
int64_t ffi_church_core_rust_future_complete_i64(uint64_t handle, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_POLL_F32
#define UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_POLL_F32
void ffi_church_core_rust_future_poll_f32(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_CANCEL_F32
#define UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_CANCEL_F32
void ffi_church_core_rust_future_cancel_f32(uint64_t handle
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_FREE_F32
#define UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_FREE_F32
void ffi_church_core_rust_future_free_f32(uint64_t handle
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_COMPLETE_F32
#define UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_COMPLETE_F32
float ffi_church_core_rust_future_complete_f32(uint64_t handle, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_POLL_F64
#define UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_POLL_F64
void ffi_church_core_rust_future_poll_f64(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_CANCEL_F64
#define UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_CANCEL_F64
void ffi_church_core_rust_future_cancel_f64(uint64_t handle
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_FREE_F64
#define UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_FREE_F64
void ffi_church_core_rust_future_free_f64(uint64_t handle
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_COMPLETE_F64
#define UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_COMPLETE_F64
double ffi_church_core_rust_future_complete_f64(uint64_t handle, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_POLL_POINTER
#define UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_POLL_POINTER
void ffi_church_core_rust_future_poll_pointer(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_CANCEL_POINTER
#define UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_CANCEL_POINTER
void ffi_church_core_rust_future_cancel_pointer(uint64_t handle
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_FREE_POINTER
#define UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_FREE_POINTER
void ffi_church_core_rust_future_free_pointer(uint64_t handle
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_COMPLETE_POINTER
#define UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_COMPLETE_POINTER
void*_Nonnull ffi_church_core_rust_future_complete_pointer(uint64_t handle, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_POLL_RUST_BUFFER
#define UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_POLL_RUST_BUFFER
void ffi_church_core_rust_future_poll_rust_buffer(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_CANCEL_RUST_BUFFER
#define UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_CANCEL_RUST_BUFFER
void ffi_church_core_rust_future_cancel_rust_buffer(uint64_t handle
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_FREE_RUST_BUFFER
#define UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_FREE_RUST_BUFFER
void ffi_church_core_rust_future_free_rust_buffer(uint64_t handle
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_COMPLETE_RUST_BUFFER
#define UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_COMPLETE_RUST_BUFFER
RustBuffer ffi_church_core_rust_future_complete_rust_buffer(uint64_t handle, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_POLL_VOID
#define UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_POLL_VOID
void ffi_church_core_rust_future_poll_void(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_CANCEL_VOID
#define UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_CANCEL_VOID
void ffi_church_core_rust_future_cancel_void(uint64_t handle
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_FREE_VOID
#define UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_FREE_VOID
void ffi_church_core_rust_future_free_void(uint64_t handle
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_COMPLETE_VOID
#define UNIFFI_FFIDEF_FFI_CHURCH_CORE_RUST_FUTURE_COMPLETE_VOID
void ffi_church_core_rust_future_complete_void(uint64_t handle, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_CHECKSUM_FUNC_CREATE_SERMON_SHARE_ITEMS_JSON
#define UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_CHECKSUM_FUNC_CREATE_SERMON_SHARE_ITEMS_JSON
uint16_t uniffi_church_core_checksum_func_create_sermon_share_items_json(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_CHECKSUM_FUNC_DEVICE_SUPPORTS_AV1
#define UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_CHECKSUM_FUNC_DEVICE_SUPPORTS_AV1
uint16_t uniffi_church_core_checksum_func_device_supports_av1(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_CHECKSUM_FUNC_EXTRACT_SCRIPTURE_REFERENCES_STRING
#define UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_CHECKSUM_FUNC_EXTRACT_SCRIPTURE_REFERENCES_STRING
uint16_t uniffi_church_core_checksum_func_extract_scripture_references_string(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_CHECKSUM_FUNC_FETCH_BIBLE_VERSE_JSON
#define UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_CHECKSUM_FUNC_FETCH_BIBLE_VERSE_JSON
uint16_t uniffi_church_core_checksum_func_fetch_bible_verse_json(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_CHECKSUM_FUNC_FETCH_BULLETINS_JSON
#define UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_CHECKSUM_FUNC_FETCH_BULLETINS_JSON
uint16_t uniffi_church_core_checksum_func_fetch_bulletins_json(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_CHECKSUM_FUNC_FETCH_CACHED_IMAGE_BASE64
#define UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_CHECKSUM_FUNC_FETCH_CACHED_IMAGE_BASE64
uint16_t uniffi_church_core_checksum_func_fetch_cached_image_base64(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_CHECKSUM_FUNC_FETCH_CONFIG_JSON
#define UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_CHECKSUM_FUNC_FETCH_CONFIG_JSON
uint16_t uniffi_church_core_checksum_func_fetch_config_json(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_CHECKSUM_FUNC_FETCH_CURRENT_BULLETIN_JSON
#define UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_CHECKSUM_FUNC_FETCH_CURRENT_BULLETIN_JSON
uint16_t uniffi_church_core_checksum_func_fetch_current_bulletin_json(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_CHECKSUM_FUNC_FETCH_EVENTS_JSON
#define UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_CHECKSUM_FUNC_FETCH_EVENTS_JSON
uint16_t uniffi_church_core_checksum_func_fetch_events_json(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_CHECKSUM_FUNC_FETCH_FEATURED_EVENTS_JSON
#define UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_CHECKSUM_FUNC_FETCH_FEATURED_EVENTS_JSON
uint16_t uniffi_church_core_checksum_func_fetch_featured_events_json(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_CHECKSUM_FUNC_FETCH_LIVE_STREAM_JSON
#define UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_CHECKSUM_FUNC_FETCH_LIVE_STREAM_JSON
uint16_t uniffi_church_core_checksum_func_fetch_live_stream_json(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_CHECKSUM_FUNC_FETCH_LIVESTREAM_ARCHIVE_JSON
#define UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_CHECKSUM_FUNC_FETCH_LIVESTREAM_ARCHIVE_JSON
uint16_t uniffi_church_core_checksum_func_fetch_livestream_archive_json(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_CHECKSUM_FUNC_FETCH_RANDOM_BIBLE_VERSE_JSON
#define UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_CHECKSUM_FUNC_FETCH_RANDOM_BIBLE_VERSE_JSON
uint16_t uniffi_church_core_checksum_func_fetch_random_bible_verse_json(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_CHECKSUM_FUNC_FETCH_SCRIPTURE_VERSES_FOR_SERMON_JSON
#define UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_CHECKSUM_FUNC_FETCH_SCRIPTURE_VERSES_FOR_SERMON_JSON
uint16_t uniffi_church_core_checksum_func_fetch_scripture_verses_for_sermon_json(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_CHECKSUM_FUNC_FETCH_SERMONS_JSON
#define UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_CHECKSUM_FUNC_FETCH_SERMONS_JSON
uint16_t uniffi_church_core_checksum_func_fetch_sermons_json(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_CHECKSUM_FUNC_FETCH_STREAM_STATUS_JSON
#define UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_CHECKSUM_FUNC_FETCH_STREAM_STATUS_JSON
uint16_t uniffi_church_core_checksum_func_fetch_stream_status_json(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_CHECKSUM_FUNC_FILTER_SERMONS_BY_MEDIA_TYPE
#define UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_CHECKSUM_FUNC_FILTER_SERMONS_BY_MEDIA_TYPE
uint16_t uniffi_church_core_checksum_func_filter_sermons_by_media_type(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_CHECKSUM_FUNC_FORMAT_EVENT_FOR_DISPLAY_JSON
#define UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_CHECKSUM_FUNC_FORMAT_EVENT_FOR_DISPLAY_JSON
uint16_t uniffi_church_core_checksum_func_format_event_for_display_json(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_CHECKSUM_FUNC_FORMAT_SCRIPTURE_TEXT_JSON
#define UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_CHECKSUM_FUNC_FORMAT_SCRIPTURE_TEXT_JSON
uint16_t uniffi_church_core_checksum_func_format_scripture_text_json(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_CHECKSUM_FUNC_FORMAT_TIME_RANGE_STRING
#define UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_CHECKSUM_FUNC_FORMAT_TIME_RANGE_STRING
uint16_t uniffi_church_core_checksum_func_format_time_range_string(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_CHECKSUM_FUNC_GENERATE_HOME_FEED_JSON
#define UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_CHECKSUM_FUNC_GENERATE_HOME_FEED_JSON
uint16_t uniffi_church_core_checksum_func_generate_home_feed_json(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_CHECKSUM_FUNC_GET_AV1_STREAMING_URL
#define UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_CHECKSUM_FUNC_GET_AV1_STREAMING_URL
uint16_t uniffi_church_core_checksum_func_get_av1_streaming_url(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_CHECKSUM_FUNC_GET_HLS_STREAMING_URL
#define UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_CHECKSUM_FUNC_GET_HLS_STREAMING_URL
uint16_t uniffi_church_core_checksum_func_get_hls_streaming_url(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_CHECKSUM_FUNC_GET_MEDIA_TYPE_DISPLAY_NAME
#define UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_CHECKSUM_FUNC_GET_MEDIA_TYPE_DISPLAY_NAME
uint16_t uniffi_church_core_checksum_func_get_media_type_display_name(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_CHECKSUM_FUNC_GET_MEDIA_TYPE_ICON
#define UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_CHECKSUM_FUNC_GET_MEDIA_TYPE_ICON
uint16_t uniffi_church_core_checksum_func_get_media_type_icon(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_CHECKSUM_FUNC_GET_OPTIMAL_STREAMING_URL
#define UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_CHECKSUM_FUNC_GET_OPTIMAL_STREAMING_URL
uint16_t uniffi_church_core_checksum_func_get_optimal_streaming_url(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_CHECKSUM_FUNC_IS_MULTI_DAY_EVENT_CHECK
#define UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_CHECKSUM_FUNC_IS_MULTI_DAY_EVENT_CHECK
uint16_t uniffi_church_core_checksum_func_is_multi_day_event_check(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_CHECKSUM_FUNC_SUBMIT_CONTACT_JSON
#define UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_CHECKSUM_FUNC_SUBMIT_CONTACT_JSON
uint16_t uniffi_church_core_checksum_func_submit_contact_json(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_CHECKSUM_FUNC_SUBMIT_CONTACT_V2_JSON
#define UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_CHECKSUM_FUNC_SUBMIT_CONTACT_V2_JSON
uint16_t uniffi_church_core_checksum_func_submit_contact_v2_json(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_CHECKSUM_FUNC_SUBMIT_CONTACT_V2_JSON_LEGACY
#define UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_CHECKSUM_FUNC_SUBMIT_CONTACT_V2_JSON_LEGACY
uint16_t uniffi_church_core_checksum_func_submit_contact_v2_json_legacy(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_CHECKSUM_FUNC_VALIDATE_CONTACT_FORM_JSON
#define UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_CHECKSUM_FUNC_VALIDATE_CONTACT_FORM_JSON
uint16_t uniffi_church_core_checksum_func_validate_contact_form_json(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_CHECKSUM_FUNC_VALIDATE_EMAIL_ADDRESS
#define UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_CHECKSUM_FUNC_VALIDATE_EMAIL_ADDRESS
uint16_t uniffi_church_core_checksum_func_validate_email_address(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_CHECKSUM_FUNC_VALIDATE_PHONE_NUMBER
#define UNIFFI_FFIDEF_UNIFFI_CHURCH_CORE_CHECKSUM_FUNC_VALIDATE_PHONE_NUMBER
uint16_t uniffi_church_core_checksum_func_validate_phone_number(void
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_CHURCH_CORE_UNIFFI_CONTRACT_VERSION
#define UNIFFI_FFIDEF_FFI_CHURCH_CORE_UNIFFI_CONTRACT_VERSION
uint32_t ffi_church_core_uniffi_contract_version(void
);
#endif

View file

@ -0,0 +1,85 @@
import SwiftUI
import UIKit
// MARK: - Global tvOS Compatibility
#if os(tvOS)
// Add missing system colors for tvOS
extension UIColor {
static var systemBackground: UIColor { return UIColor.black }
static var secondarySystemBackground: UIColor { return UIColor.gray.withAlphaComponent(0.1) }
static var systemGray6: UIColor { return UIColor.gray.withAlphaComponent(0.2) }
}
// Dummy UIActivityViewController for tvOS
class UIActivityViewController: UIViewController {
convenience init(activityItems: [Any], applicationActivities: [UIActivity]?) {
self.init()
}
}
// Add missing NavigationBarItem types
extension NavigationBarItem {
enum TitleDisplayMode {
case automatic
case inline
case large
}
}
#endif
// MARK: - SwiftUI Extensions
extension Color {
static var systemBackground: Color {
#if os(iOS)
return Color(.systemBackground)
#else
return Color.black
#endif
}
static var secondarySystemBackground: Color {
#if os(iOS)
return Color(.secondarySystemBackground)
#else
return Color.secondary.opacity(0.1)
#endif
}
static var systemGray6: Color {
#if os(iOS)
return Color(.systemGray6)
#else
return Color.gray.opacity(0.2)
#endif
}
}
// MARK: - View Extensions for tvOS Compatibility
extension View {
func navigationBarTitleDisplayMode(_ mode: NavigationBarItem.TitleDisplayMode) -> some View {
#if os(iOS)
return self.navigationBarTitleDisplayMode(mode)
#else
return self
#endif
}
func navigationBarBackButtonHidden(_ hidden: Bool) -> some View {
#if os(iOS)
return self.navigationBarBackButtonHidden(hidden)
#else
return self
#endif
}
func listRowSeparator(_ visibility: Visibility, edges: VerticalEdge.Set = .all) -> some View {
#if os(iOS)
return self.listRowSeparator(visibility, edges: edges)
#else
return self
#endif
}
}

View file

@ -1,8 +1,12 @@
import Foundation import Foundation
#if canImport(EventKit)
import EventKit import EventKit
#endif
import CoreLocation import CoreLocation
import Photos import Photos
#if canImport(Contacts)
import Contacts import Contacts
#endif
import AVFoundation import AVFoundation
@MainActor @MainActor
@ -33,6 +37,7 @@ class PermissionsManager: ObservableObject {
// MARK: - Calendar // MARK: - Calendar
func checkCalendarAccess() { func checkCalendarAccess() {
#if canImport(EventKit)
if #available(iOS 17.0, *) { if #available(iOS 17.0, *) {
let status = EKEventStore.authorizationStatus(for: .event) let status = EKEventStore.authorizationStatus(for: .event)
calendarAccess = status == .fullAccess || status == .writeOnly calendarAccess = status == .fullAccess || status == .writeOnly
@ -40,9 +45,13 @@ class PermissionsManager: ObservableObject {
let status = EKEventStore.authorizationStatus(for: .event) let status = EKEventStore.authorizationStatus(for: .event)
calendarAccess = status == .authorized calendarAccess = status == .authorized
} }
#else
calendarAccess = false
#endif
} }
func requestCalendarAccess() async -> Bool { func requestCalendarAccess() async -> Bool {
#if canImport(EventKit)
let store = EKEventStore() let store = EKEventStore()
do { do {
if #available(iOS 17.0, *) { if #available(iOS 17.0, *) {
@ -104,6 +113,9 @@ class PermissionsManager: ObservableObject {
print("❌ Calendar access error: \(error)") print("❌ Calendar access error: \(error)")
return false return false
} }
#else
return false
#endif
} }
// MARK: - Location // MARK: - Location
@ -150,11 +162,16 @@ class PermissionsManager: ObservableObject {
// MARK: - Contacts // MARK: - Contacts
func checkContactsAccess() { func checkContactsAccess() {
#if canImport(Contacts)
let status = CNContactStore.authorizationStatus(for: .contacts) let status = CNContactStore.authorizationStatus(for: .contacts)
contactsAccess = status == .authorized contactsAccess = status == .authorized
#else
contactsAccess = false
#endif
} }
func requestContactsAccess() async -> Bool { func requestContactsAccess() async -> Bool {
#if canImport(Contacts)
let store = CNContactStore() let store = CNContactStore()
do { do {
let granted = try await store.requestAccess(for: .contacts) let granted = try await store.requestAccess(for: .contacts)
@ -166,6 +183,9 @@ class PermissionsManager: ObservableObject {
print("❌ Contacts access error: \(error)") print("❌ Contacts access error: \(error)")
return false return false
} }
#else
return false
#endif
} }
// MARK: - Microphone // MARK: - Microphone
@ -186,6 +206,7 @@ class PermissionsManager: ObservableObject {
func handleLimitedAccess(for feature: AppFeature) -> FeatureAvailability { func handleLimitedAccess(for feature: AppFeature) -> FeatureAvailability {
switch feature { switch feature {
case .calendar: case .calendar:
#if canImport(EventKit)
if #available(iOS 17.0, *) { if #available(iOS 17.0, *) {
let status = EKEventStore.authorizationStatus(for: .event) let status = EKEventStore.authorizationStatus(for: .event)
switch status { switch status {
@ -203,6 +224,9 @@ class PermissionsManager: ObservableObject {
} }
return .unavailable return .unavailable
} }
#else
return .unavailable
#endif
case .location: case .location:
return locationAccess ? .full : .limited return locationAccess ? .full : .limited
case .camera: case .camera:
@ -219,7 +243,11 @@ class PermissionsManager: ObservableObject {
return .unavailable return .unavailable
} }
case .contacts: case .contacts:
#if canImport(Contacts)
return contactsAccess ? .full : .limited return contactsAccess ? .full : .limited
#else
return .unavailable
#endif
case .microphone: case .microphone:
return microphoneAccess ? .full : .limited return microphoneAccess ? .full : .limited
} }

View file

@ -1,66 +0,0 @@
import Foundation
struct BulletinSection: Identifiable {
let id = UUID()
let title: String
let content: String
}
struct Bulletin: Identifiable, Codable {
let id: String
let title: String
let date: Date
let sections: [BulletinSection]
let pdfUrl: String?
let isActive: Bool
let created: Date
let updated: Date
enum CodingKeys: String, CodingKey {
case id
case title
case date
case sections
case pdfUrl = "pdf_url"
case isActive = "is_active"
case created
case updated
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(String.self, forKey: .id)
title = try container.decode(String.self, forKey: .title)
date = try container.decode(Date.self, forKey: .date)
pdfUrl = try container.decodeIfPresent(String.self, forKey: .pdfUrl)
isActive = try container.decode(Bool.self, forKey: .isActive)
created = try container.decode(Date.self, forKey: .created)
updated = try container.decode(Date.self, forKey: .updated)
// Decode sections
let sectionsData = try container.decode([[String: String]].self, forKey: .sections)
sections = sectionsData.map { section in
BulletinSection(
title: section["title"] ?? "",
content: section["content"] ?? ""
)
}
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(title, forKey: .title)
try container.encode(date, forKey: .date)
try container.encodeIfPresent(pdfUrl, forKey: .pdfUrl)
try container.encode(isActive, forKey: .isActive)
try container.encode(created, forKey: .created)
try container.encode(updated, forKey: .updated)
// Encode sections
let sectionsData = sections.map { section in
["title": section.title, "content": section.content]
}
try container.encode(sectionsData, forKey: .sections)
}
}

View file

@ -3,13 +3,47 @@ import Foundation
struct Config: Codable { struct Config: Codable {
let id: String let id: String
let churchName: String let churchName: String
let tagline: String
let contactEmail: String let contactEmail: String
let contactPhone: String let contactPhone: String
let churchAddress: String let churchAddress: String
let coordinates: Coordinates
let donationUrl: String
let brandColor: String
let googleMapsUrl: String let googleMapsUrl: String
let aboutText: String let aboutText: String
let poBox: String
let serviceTimes: [ServiceTime]
let apiKeys: APIKeys let apiKeys: APIKeys
enum CodingKeys: String, CodingKey {
case id
case churchName = "church_name"
case tagline
case contactEmail = "contact_email"
case contactPhone = "contact_phone"
case churchAddress = "church_address"
case coordinates
case donationUrl = "donation_url"
case brandColor = "brand_color"
case googleMapsUrl = "google_maps_url"
case aboutText = "about_text"
case poBox = "po_box"
case serviceTimes = "service_times"
case apiKeys = "api_keys"
}
struct Coordinates: Codable {
let lat: Double
let lng: Double
}
struct ServiceTime: Codable {
let day: String
let time: String
let service: String
}
struct APIKeys: Codable { struct APIKeys: Codable {
let bibleApiKey: String let bibleApiKey: String
let jellyfinApiKey: String let jellyfinApiKey: String
@ -18,18 +52,10 @@ struct Config: Codable {
case bibleApiKey = "bible_api_key" case bibleApiKey = "bible_api_key"
case jellyfinApiKey = "jellyfin_api_key" case jellyfinApiKey = "jellyfin_api_key"
} }
} }
enum CodingKeys: String, CodingKey { }
case id
case churchName = "church_name"
case contactEmail = "contact_email"
case contactPhone = "contact_phone"
case churchAddress = "church_address"
case googleMapsUrl = "google_maps_url"
case aboutText = "about_text"
case apiKeys = "api_key"
}
} }
struct ConfigResponse: Codable { struct ConfigResponse: Codable {
@ -37,13 +63,14 @@ struct ConfigResponse: Codable {
let perPage: Int let perPage: Int
let totalPages: Int let totalPages: Int
let totalItems: Int let totalItems: Int
let items: [Config]
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case page case page
case perPage = "perPage" case perPage = "per_page"
case totalPages = "totalPages" case totalPages = "total_pages"
case totalItems = "totalItems" case totalItems = "total_items"
case items case items
} }
let items: [Config]
} }

419
Models/ContentModels.swift Normal file
View file

@ -0,0 +1,419 @@
import Foundation
// MARK: - Content Models for RTSDA v2.0
struct ChurchEvent: Identifiable, Codable, Hashable {
let id: String
let title: String
let description: String
// Raw ISO timestamps for calendar/system APIs
let startTime: String
let endTime: String
// Formatted display strings from Rust (RTSDA Architecture Rules compliance)
let formattedTime: String // "6:00 PM - 8:00 PM"
let formattedDate: String // "Friday, August 15, 2025"
let formattedDateTime: String // "Friday, August 15, 2025 at 6:00 PM"
// Additional display fields from Rust (RTSDA Architecture Rules compliance)
let dayOfMonth: String // "15"
let monthAbbreviation: String // "AUG"
let timeString: String // "6:00 PM - 8:00 PM" (alias for formattedTime)
let isMultiDay: Bool // true if event spans multiple days
let detailedTimeDisplay: String // Full time range for detail views
let location: String
let locationUrl: String?
let image: String?
let thumbnail: String?
let category: String
let isFeatured: Bool
let recurringType: String?
let createdAt: String
let updatedAt: String
enum CodingKeys: String, CodingKey {
case id, title, description, location, image, thumbnail, category
case startTime = "start_time"
case endTime = "end_time"
case formattedTime = "formatted_time"
case formattedDate = "formatted_date"
case formattedDateTime = "formatted_date_time"
case dayOfMonth = "day_of_month"
case monthAbbreviation = "month_abbreviation"
case timeString = "time_string"
case isMultiDay = "is_multi_day"
case detailedTimeDisplay = "detailed_time_display"
case locationUrl = "location_url"
case isFeatured = "is_featured"
case recurringType = "recurring_type"
case createdAt = "created_at"
case updatedAt = "updated_at"
}
// All formatting now handled by Rust church-core crate (RTSDA Architecture Rules compliance)
/// Returns formatted date range for display - now using Rust-provided formattedDate
var formattedDateRange: String {
return formattedDate
}
// MARK: - Sample Data
static func sampleEvent() -> ChurchEvent {
return ChurchEvent(
id: "sample-event-1",
title: "Community Potluck",
description: "Join us for fellowship and food",
startTime: "2025-01-15T18:00:00-05:00",
endTime: "2025-01-15T20:00:00-05:00",
formattedTime: "6:00 PM - 8:00 PM",
formattedDate: "January 15, 2025",
formattedDateTime: "January 15, 2025 at 6:00 PM",
dayOfMonth: "15",
monthAbbreviation: "JAN",
timeString: "6:00 PM - 8:00 PM",
isMultiDay: false,
detailedTimeDisplay: "6:00 PM - 8:00 PM",
location: "Fellowship Hall",
locationUrl: nil,
image: nil,
thumbnail: nil,
category: "Social",
isFeatured: false,
recurringType: nil,
createdAt: "2025-01-10T09:00:00-05:00",
updatedAt: "2025-01-10T09:00:00-05:00"
)
}
}
struct Sermon: Identifiable, Codable {
let id: String
let title: String
let speaker: String
let description: String?
let date: String?
let audioUrl: String?
let videoUrl: String?
let duration: String?
let mediaType: String?
let thumbnail: String?
let image: String?
let scriptureReading: String?
// CodingKeys no longer needed - Rust now sends camelCase field names
var formattedDate: String {
return date ?? "Date unknown" // Already formatted by API
}
var durationFormatted: String? {
return duration // Already formatted by API
}
// MARK: - Sample Data
static func sampleSermon() -> Sermon {
return Sermon(
id: "sample-1",
title: "Walking in Faith During Difficult Times",
speaker: "Pastor John Smith",
description: "A message about trusting God during challenging times.",
date: "January 10th, 2025",
audioUrl: nil,
videoUrl: "https://example.com/video.mp4",
duration: "35:42",
mediaType: "Video",
thumbnail: nil,
image: nil,
scriptureReading: "Philippians 4:13"
)
}
}
struct ChurchBulletin: Identifiable, Codable {
let id: String
let title: String
let date: String
let sabbathSchool: String
let divineWorship: String
let scriptureReading: String
let sunset: String
let pdfPath: String?
let coverImage: String?
let isActive: Bool
enum CodingKeys: String, CodingKey {
case id, title, date, sunset
case sabbathSchool = "sabbath_school"
case divineWorship = "divine_worship"
case scriptureReading = "scripture_reading"
case pdfPath = "pdf_path"
case coverImage = "cover_image"
case isActive = "is_active"
}
var formattedDate: String {
// Parse ISO8601 or YYYY-MM-DD format and return US-friendly date
let formatter = ISO8601DateFormatter()
if let isoDate = formatter.date(from: date) {
let usFormatter = DateFormatter()
usFormatter.dateStyle = .long // "August 2, 2025"
return usFormatter.string(from: isoDate)
} else if date.contains("-") {
// Try parsing YYYY-MM-DD format
let components = date.split(separator: "-")
if components.count >= 3,
let year = Int(components[0]),
let month = Int(components[1]),
let day = Int(components[2]) {
let dateComponents = DateComponents(year: year, month: month, day: day)
if let parsedDate = Calendar.current.date(from: dateComponents) {
let usFormatter = DateFormatter()
usFormatter.dateStyle = .long // "August 2, 2025"
return usFormatter.string(from: parsedDate)
}
}
}
return date // Fallback to original if parsing fails
}
}
struct BibleVerse: Identifiable, Codable {
let text: String
let reference: String
let version: String?
let book: String?
let chapter: UInt32?
let verse: UInt32?
let category: String?
// Computed property for Identifiable
var id: String {
return reference + text.prefix(50) // Use reference + start of text as unique ID
}
}
// MARK: - API Response Models
struct EventsResponse: Codable {
let items: [ChurchEvent]
let total: Int
let page: Int
let perPage: Int
let hasMore: Bool
enum CodingKeys: String, CodingKey {
case items, total, page
case perPage = "per_page"
case hasMore = "has_more"
}
}
struct ContactSubmissionResult: Codable {
let success: Bool
let message: String?
}
// MARK: - Content Feed Item
enum FeedItemType {
case sermon(Sermon)
case event(ChurchEvent)
case bulletin(ChurchBulletin)
case verse(BibleVerse)
}
// MARK: - Rust Feed Item (from church-core)
struct RustFeedItem: Identifiable, Codable {
let id: String
let feedType: RustFeedItemType
enum CodingKeys: String, CodingKey {
case id
case feedType = "feed_type"
case timestamp
case priority
}
let timestamp: String // ISO8601 format
let priority: Int32
}
enum RustFeedItemType: Codable {
case event(ChurchEvent)
case sermon(Sermon)
case bulletin(ChurchBulletin)
case verse(BibleVerse)
enum CodingKeys: String, CodingKey {
case type
case event
case sermon
case bulletin
case verse
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let type = try container.decode(String.self, forKey: .type)
switch type {
case "event":
let event = try container.decode(ChurchEvent.self, forKey: .event)
self = .event(event)
case "sermon":
let sermon = try container.decode(Sermon.self, forKey: .sermon)
self = .sermon(sermon)
case "bulletin":
let bulletin = try container.decode(ChurchBulletin.self, forKey: .bulletin)
self = .bulletin(bulletin)
case "verse":
let verse = try container.decode(BibleVerse.self, forKey: .verse)
self = .verse(verse)
default:
throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Unknown feed item type: \(type)"))
}
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
switch self {
case .event(let event):
try container.encode("event", forKey: .type)
try container.encode(event, forKey: .event)
case .sermon(let sermon):
try container.encode("sermon", forKey: .type)
try container.encode(sermon, forKey: .sermon)
case .bulletin(let bulletin):
try container.encode("bulletin", forKey: .type)
try container.encode(bulletin, forKey: .bulletin)
case .verse(let verse):
try container.encode("verse", forKey: .type)
try container.encode(verse, forKey: .verse)
}
}
}
// MARK: - Swift Feed Item (legacy)
struct FeedItem: Identifiable {
let id = UUID()
let type: FeedItemType
let timestamp: Date
var title: String {
switch type {
case .sermon(let sermon):
return sermon.title
case .event(let event):
return event.title
case .bulletin(let bulletin):
return bulletin.title
case .verse(let verse):
return verse.reference
}
}
var subtitle: String? {
switch type {
case .sermon(let sermon):
return sermon.speaker
case .event(let event):
return event.formattedDate
case .bulletin(let bulletin):
return bulletin.formattedDate
case .verse(let verse):
return verse.text
}
}
}
// MARK: - Search Utilities
struct SearchUtils {
/// Searches events by title, description, location, and date
static func searchEvents(_ events: [ChurchEvent], searchText: String) -> [ChurchEvent] {
guard !searchText.isEmpty else { return events }
return events.filter { event in
let titleMatch = event.title.localizedCaseInsensitiveContains(searchText)
let descMatch = event.description.localizedCaseInsensitiveContains(searchText)
let locationMatch = event.location.localizedCaseInsensitiveContains(searchText)
let dateMatch = event.formattedDate.localizedCaseInsensitiveContains(searchText)
let smartDateMatch = checkSmartDateMatch(searchText: searchText, sermonDate: event.formattedDate)
return titleMatch || descMatch || locationMatch || dateMatch || smartDateMatch
}
}
/// Searches sermons by title, speaker, description, and date
static func searchSermons(_ sermons: [Sermon], searchText: String, contentType: String = "sermons") -> [Sermon] {
guard !searchText.isEmpty else { return sermons }
return sermons.filter { sermon in
let titleMatch = sermon.title.localizedCaseInsensitiveContains(searchText)
let speakerMatch = sermon.speaker.localizedCaseInsensitiveContains(searchText)
let descMatch = sermon.description?.localizedCaseInsensitiveContains(searchText) ?? false
let dateMatch = sermon.date?.localizedCaseInsensitiveContains(searchText) ?? false
let smartDateMatch = checkSmartDateMatch(searchText: searchText, sermonDate: sermon.date)
return titleMatch || speakerMatch || descMatch || dateMatch || smartDateMatch
}
}
/// Smart date matching for patterns like "January 2025", "Jan 2024", etc.
private static func checkSmartDateMatch(searchText: String, sermonDate: String?) -> Bool {
guard let sermonDate = sermonDate else { return false }
let searchWords = searchText.lowercased().components(separatedBy: .whitespaces).filter { !$0.isEmpty }
guard searchWords.count >= 2 else { return false }
// Check for month + year patterns like "January 2025" or "Jan 2024"
let monthNames = ["january", "february", "march", "april", "may", "june",
"july", "august", "september", "october", "november", "december"]
let monthAbbrevs = ["jan", "feb", "mar", "apr", "may", "jun",
"jul", "aug", "sep", "oct", "nov", "dec"]
for i in 0..<searchWords.count-1 {
let word1 = searchWords[i]
let word2 = searchWords[i+1]
// Check if first word is a month (full name or abbreviation)
if monthNames.contains(word1) || monthAbbrevs.contains(word1) {
// Convert month to full name for matching
let fullMonthName: String
if let monthIndex = monthAbbrevs.firstIndex(of: word1) {
fullMonthName = monthNames[monthIndex]
} else {
fullMonthName = word1
}
let sermonDateLower = sermonDate.lowercased()
// Check if second word is a year
if let year = Int(word2), year >= 2000 && year <= 2100 {
// Check if sermon date contains both the month and year
if sermonDateLower.contains(fullMonthName) && sermonDateLower.contains(String(year)) {
return true
}
}
// Check if second word could be a day OR part of a year
else if let number = Int(word2) {
// Check if sermon date contains both the month and this number anywhere
// This catches both day matches (e.g., "January 20th") and year substring matches (e.g., "January" + "20" in "2020")
if sermonDateLower.contains(fullMonthName) && sermonDateLower.contains(String(number)) {
return true
}
}
}
}
return false
}
}

View file

@ -1,403 +0,0 @@
import Foundation
import EventKit
import UIKit
struct Event: Identifiable, Codable {
let id: String
let title: String
let description: String // Original HTML description
let startDate: Date
let endDate: Date
let location: String?
let locationURL: String?
let image: String?
let thumbnail: String?
let category: EventCategory
let isFeatured: Bool
let reoccuring: ReoccurringType
let isPublished: Bool
let created: Date
let updated: Date
enum EventCategory: String, Codable {
case service = "Service"
case social = "Social"
case ministry = "Ministry"
case other = "Other"
}
enum ReoccurringType: String, Codable {
case none = "" // For non-recurring events
case daily = "DAILY"
case weekly = "WEEKLY"
case biweekly = "BIWEEKLY"
case firstTuesday = "FIRST_TUESDAY"
var calendarRecurrenceRule: EKRecurrenceRule? {
switch self {
case .none:
return nil // No recurrence for one-time events
case .daily:
return EKRecurrenceRule(
recurrenceWith: .daily,
interval: 1,
end: nil
)
case .weekly:
return EKRecurrenceRule(
recurrenceWith: .weekly,
interval: 1,
end: nil
)
case .biweekly:
return EKRecurrenceRule(
recurrenceWith: .weekly,
interval: 2,
end: nil
)
case .firstTuesday:
let tuesday = EKWeekday.tuesday
return EKRecurrenceRule(
recurrenceWith: .monthly,
interval: 1,
daysOfTheWeek: [EKRecurrenceDayOfWeek(tuesday)],
daysOfTheMonth: nil,
monthsOfTheYear: nil,
weeksOfTheYear: nil,
daysOfTheYear: nil,
setPositions: [1],
end: nil
)
}
}
}
var formattedDateTime: String {
let dateFormatter = DateFormatter()
dateFormatter.dateStyle = .medium
dateFormatter.timeStyle = .short
dateFormatter.timeZone = .gmt // Use GMT to match database times exactly
let startDateString = dateFormatter.string(from: startDate)
let endTimeFormatter = DateFormatter()
endTimeFormatter.timeStyle = .short
endTimeFormatter.timeZone = .gmt // Use GMT to match database times exactly
let endTimeString = endTimeFormatter.string(from: endDate)
return "\(startDateString)\(endTimeString)"
}
var hasLocation: Bool {
return (location != nil && !location!.isEmpty)
}
var hasLocationUrl: Bool {
return (locationURL != nil && !locationURL!.isEmpty)
}
var canOpenInMaps: Bool {
return hasLocation
}
var displayLocation: String {
if let location = location {
return location
}
if let locationURL = locationURL {
// Try to extract a readable location from the URL
if let url = URL(string: locationURL) {
let components = url.pathComponents
if components.count > 1 {
return components.last?.replacingOccurrences(of: "+", with: " ") ?? locationURL
}
}
return locationURL
}
return "No location specified"
}
var imageURL: URL? {
guard let image = image else { return nil }
return URL(string: "https://pocketbase.rockvilletollandsda.church/api/files/events/\(id)/\(image)")
}
var thumbnailURL: URL? {
guard let thumbnail = thumbnail else { return nil }
return URL(string: "https://pocketbase.rockvilletollandsda.church/api/files/events/\(id)/\(thumbnail)")
}
func callPhone() {
if let phoneNumber = extractPhoneNumber() {
let cleanNumber = phoneNumber.replacingOccurrences(of: "[^0-9+]", with: "", options: .regularExpression)
if let url = URL(string: "tel://\(cleanNumber)") {
UIApplication.shared.open(url)
}
}
}
func extractPhoneNumber() -> String? {
let phonePattern = #"Phone:.*?(\+\d{1})?[\s-]?\(?(\d{3})\)?[-.\s]?(\d{3})[-.\s]?(\d{4})"#
if let match = description.range(of: phonePattern, options: .regularExpression) {
let phoneText = String(description[match])
let numberPattern = #"(\+\d{1})?[\s-]?\(?(\d{3})\)?[-.\s]?(\d{3})[-.\s]?(\d{4})"#
if let numberMatch = phoneText.range(of: numberPattern, options: .regularExpression) {
return String(phoneText[numberMatch])
}
}
return nil
}
var plainDescription: String {
// First remove all table structures and divs
var cleanedText = description.replacingOccurrences(of: "<table[^>]*>.*?</table>", with: "", options: .regularExpression)
cleanedText = cleanedText.replacingOccurrences(of: "<div[^>]*>", with: "", options: .regularExpression)
cleanedText = cleanedText.replacingOccurrences(of: "</div>", with: "\n", options: .regularExpression)
// Replace other HTML tags
cleanedText = cleanedText.replacingOccurrences(of: "<br\\s*/?>", with: "\n", options: .regularExpression)
cleanedText = cleanedText.replacingOccurrences(of: "<p>", with: "", options: .regularExpression)
cleanedText = cleanedText.replacingOccurrences(of: "</p>", with: "\n", options: .regularExpression)
cleanedText = cleanedText.replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression)
// Decode common HTML entities
let htmlEntities = [
"&nbsp;": " ",
"&amp;": "&",
"&lt;": "<",
"&gt;": ">",
"&quot;": "\"",
"&apos;": "'",
"&#x27;": "'",
"&#x2F;": "/",
"&#39;": "'",
"&#47;": "/",
"&rsquo;": "'",
"&mdash;": ""
]
for (entity, replacement) in htmlEntities {
cleanedText = cleanedText.replacingOccurrences(of: entity, with: replacement)
}
// Format phone numbers with better pattern matching
let phonePattern = #"(?m)^Phone:.*?(\+\d{1})?[\s-]?\(?(\d{3})\)?[-.\s]?(\d{3})[-.\s]?(\d{4})"#
cleanedText = cleanedText.replacingOccurrences(
of: phonePattern,
with: "📞 Phone: ($2) $3-$4",
options: .regularExpression
)
// Clean up whitespace while preserving intentional line breaks
let lines = cleanedText.components(separatedBy: .newlines)
let nonEmptyLines = lines.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
return nonEmptyLines.joined(separator: "\n")
}
func openInMaps() async {
let permissionsManager = await PermissionsManager.shared
// We don't strictly need location permission to open maps,
// but we'll request it for better functionality
await permissionsManager.requestLocationAccess()
if let locationURL = locationURL, let url = URL(string: locationURL) {
await UIApplication.shared.open(url, options: [:])
} else if let location = location, !location.isEmpty {
let searchQuery = location.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? location
if let mapsUrl = URL(string: "http://maps.apple.com/?q=\(searchQuery)") {
await UIApplication.shared.open(mapsUrl, options: [:])
}
}
}
func addToCalendar(completion: @escaping (Bool, Error?) -> Void) async {
let permissionsManager = await PermissionsManager.shared
let eventStore = EKEventStore()
do {
let accessGranted = await permissionsManager.requestCalendarAccess()
if !accessGranted {
await MainActor.run {
completion(false, NSError(domain: "com.rtsda.calendar", code: 1,
userInfo: [NSLocalizedDescriptionKey: "Calendar access is not available. You can enable it in Settings."]))
}
return
}
let event = EKEvent(eventStore: eventStore)
// Set basic event details
event.title = self.title
event.notes = self.plainDescription
event.startDate = self.startDate
event.endDate = self.endDate
event.location = self.location ?? self.locationURL
// Set recurrence rule if applicable
if let rule = self.reoccuring.calendarRecurrenceRule {
event.recurrenceRules = [rule]
}
// Get the default calendar
guard let calendar = eventStore.defaultCalendarForNewEvents else {
await MainActor.run {
completion(false, NSError(domain: "com.rtsda.calendar", code: 2,
userInfo: [NSLocalizedDescriptionKey: "Could not access default calendar."]))
}
return
}
event.calendar = calendar
try eventStore.save(event, span: .thisEvent)
await MainActor.run {
completion(true, nil)
}
} catch {
await MainActor.run {
completion(false, error)
}
}
}
enum CodingKeys: String, CodingKey {
case id
case title
case description
case startDate = "start_time"
case endDate = "end_time"
case location
case locationURL = "location_url"
case image
case thumbnail
case category
case isFeatured = "is_featured"
case reoccuring
case isPublished = "is_published"
case created
case updated
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(String.self, forKey: .id)
title = try container.decode(String.self, forKey: .title)
description = try container.decode(String.self, forKey: .description)
// Try multiple date formats
let startDateString = try container.decode(String.self, forKey: .startDate)
let endDateString = try container.decode(String.self, forKey: .endDate)
let createdString = try container.decode(String.self, forKey: .created)
let updatedString = try container.decode(String.self, forKey: .updated)
// Create formatters for different possible formats
let formatters = [
{ () -> DateFormatter in
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSSZ"
formatter.timeZone = TimeZone(identifier: "America/New_York")! // Eastern Time
return formatter
}(),
{ () -> ISO8601DateFormatter in
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
formatter.timeZone = TimeZone(identifier: "America/New_York")! // Eastern Time
return formatter
}(),
{ () -> ISO8601DateFormatter in
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime]
formatter.timeZone = TimeZone(identifier: "America/New_York")! // Eastern Time
return formatter
}()
]
// Function to try parsing date with multiple formatters
func parseDate(_ dateString: String, field: String) throws -> Date {
// Print the date string we're trying to parse
print("🗓️ Trying to parse date: \(dateString) for field: \(field)")
for formatter in formatters {
if let date = (formatter as? ISO8601DateFormatter)?.date(from: dateString) ??
(formatter as? DateFormatter)?.date(from: dateString) {
print("✅ Successfully parsed date using \(type(of: formatter))")
return date
}
}
throw DecodingError.dataCorruptedError(
forKey: CodingKeys(stringValue: field)!,
in: container,
debugDescription: "Date string '\(dateString)' does not match any expected format"
)
}
// Parse all dates
startDate = try parseDate(startDateString, field: "start_time")
endDate = try parseDate(endDateString, field: "end_time")
created = try parseDate(createdString, field: "created")
updated = try parseDate(updatedString, field: "updated")
// Decode remaining fields
location = try container.decodeIfPresent(String.self, forKey: .location)
locationURL = try container.decodeIfPresent(String.self, forKey: .locationURL)
image = try container.decodeIfPresent(String.self, forKey: .image)
thumbnail = try container.decodeIfPresent(String.self, forKey: .thumbnail)
category = try container.decode(EventCategory.self, forKey: .category)
isFeatured = try container.decode(Bool.self, forKey: .isFeatured)
reoccuring = try container.decode(ReoccurringType.self, forKey: .reoccuring)
isPublished = try container.decodeIfPresent(Bool.self, forKey: .isPublished) ?? true // Default to true if not present
}
init(id: String = UUID().uuidString,
title: String,
description: String,
startDate: Date,
endDate: Date,
location: String? = nil,
locationURL: String? = nil,
image: String? = nil,
thumbnail: String? = nil,
category: EventCategory,
isFeatured: Bool = false,
reoccuring: ReoccurringType,
isPublished: Bool = true,
created: Date = Date(),
updated: Date = Date()) {
self.id = id
self.title = title
self.description = description
self.startDate = startDate
self.endDate = endDate
self.location = location
self.locationURL = locationURL
self.image = image
self.thumbnail = thumbnail
self.category = category
self.isFeatured = isFeatured
self.reoccuring = reoccuring
self.isPublished = isPublished
self.created = created
self.updated = updated
}
}
struct EventResponse: Codable {
let page: Int
let perPage: Int
let totalPages: Int
let totalItems: Int
let items: [Event]
enum CodingKeys: String, CodingKey {
case page
case perPage = "perPage"
case totalPages = "totalPages"
case totalItems = "totalItems"
case items
}
}

View file

@ -1,57 +0,0 @@
import Foundation
struct Message: Identifiable {
let id: String
let title: String
let description: String
let speaker: String
let videoUrl: String
let thumbnailUrl: String?
let duration: TimeInterval
let isLiveStream: Bool
let isPublished: Bool
let isDeleted: Bool
let liveBroadcastStatus: String // "none", "upcoming", "live", or "completed"
let date: String // ISO8601 formatted date string
var formattedDuration: String {
let formatter = DateComponentsFormatter()
formatter.allowedUnits = [.hour, .minute]
formatter.unitsStyle = .abbreviated
formatter.maximumUnitCount = 2
return formatter.string(from: duration) ?? ""
}
var formattedDate: String {
// Parse the date string
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd"
dateFormatter.timeZone = TimeZone(identifier: "America/New_York")
guard let date = dateFormatter.date(from: date) else { return date }
// Format for display
let displayFormatter = DateFormatter()
displayFormatter.dateFormat = "MMMM d, yyyy"
displayFormatter.timeZone = TimeZone(identifier: "America/New_York")
return displayFormatter.string(from: date)
}
}
// MARK: - Codable
extension Message: Codable {
enum CodingKeys: String, CodingKey {
case id
case title
case description
case speaker
case videoUrl
case thumbnailUrl
case duration
case isLiveStream
case isPublished
case isDeleted
case liveBroadcastStatus
case date
}
}

View file

@ -1,28 +0,0 @@
import Foundation
struct Sermon: Identifiable {
let id: String
let title: String
let description: String
let date: Date
let speaker: String
let type: SermonType
let videoUrl: String?
let thumbnail: String?
init(id: String, title: String, description: String, date: Date, speaker: String, type: SermonType, videoUrl: String?, thumbnail: String?) {
self.id = id
self.title = title
self.description = description
self.date = date
self.speaker = speaker
self.type = type
self.videoUrl = videoUrl
self.thumbnail = thumbnail
}
}
enum SermonType: String {
case sermon = "Sermons"
case liveArchive = "LiveStreams"
}

View file

@ -5,72 +5,95 @@ The official iOS app for the Rockville-Tolland Seventh-day Adventist Church. Thi
## Features ## Features
- **Live Streaming**: Watch church services live through OwnCast integration - **Live Streaming**: Watch church services live through OwnCast integration
- **Sermon Library**: Access archived sermons and special programs via Jellyfin - **Video Library**: Access archived sermons and special programs via Jellyfin
- **Digital Bulletin**: - **Digital Bulletin**:
- View weekly church bulletins - View weekly church bulletins with enhanced formatting
- Interactive hymn links that open in the Adventist Hymnal app - Interactive hymn links that open in the Adventist Hymnal app
- Bible verse links that open in YouVersion Bible app - Bible verse links that open in YouVersion Bible app
- PDF download option for offline viewing - PDF download option for offline viewing
- **Church Bulletin**: Stay updated with church announcements and events - Responsive reading support
- **Events & Calendar**: Stay updated with church announcements and upcoming events
- **Contact Form**: Direct communication with church staff
- **Church Information**: Access church beliefs, contact information, and more - **Church Information**: Access church beliefs, contact information, and more
- **Home Feed**: Unified view of latest bulletins, events, and media content
## Technical Details ## Architecture
- Built with SwiftUI ### Version 2.0 - Major Rewrite
- Minimum iOS version: 17.0 This version represents a complete architectural overhaul with significant improvements:
- Uses async/await for network operations
- Integrates with multiple services: - **Unified Data Layer**: All networking consolidated into a single `ChurchService` powered by the church_core Rust library
- **Performance Improvements**: 60% code reduction with optimized Rust-based networking
- **Consistent API**: Same backend used across website, iOS app, and Beacon platform
- **Enhanced UI**: Completely redesigned interface with improved navigation and user experience
- **Better Error Handling**: Robust error management with user-friendly messaging
### Technical Stack
- **Frontend**: SwiftUI with iOS 17.0+ features
- **Backend Integration**: church_core Rust library via UniFFI bindings
- **Networking**: Optimized Rust reqwest with built-in caching
- **Media Integration**:
- Jellyfin for video content - Jellyfin for video content
- OwnCast for live streaming - OwnCast for live streaming
- PocketBase for church data - External app integration (YouVersion Bible, Adventist Hymnal)
- YouVersion Bible API for verse content
- Adventist Hymnal app integration
## Building the App ## Building the App
1. Clone the repository ### Prerequisites
2. Open `RTSDA.xcodeproj` in Xcode
3. Build and run the project
## Requirements
- Xcode 15.0 or later - Xcode 15.0 or later
- iOS 17.0 or later - iOS 17.0 or later
- Swift 5.9 or later - Swift 5.9 or later
### Setup
1. Clone the repository:
```bash
git clone ssh://rockvilleav@git.rockvilletollandsda.church:10443/RTSDA/RTSDA-iOS.git
```
2. Open `RTSDA.xcodeproj` in Xcode
3. Ensure the church_core framework is properly linked
4. Build and run the project
### Dependencies
The app includes the following bundled dependencies:
- **ChurchCore.xcframework**: Rust-based networking library
- **UniFFI Bindings**: Swift bindings for church_core functionality
## Version History ## Version History
### Version 2.0 (Current)
**Major Release - Complete Rewrite**
- 🚀 **Unified ChurchService**: Replaced 4 separate networking services with single optimized service
- ⚡ **60% Code Reduction**: Eliminated duplicate networking code for better maintainability
- 🎨 **UI/UX Overhaul**: Completely redesigned interface with improved navigation
- 📱 **Enhanced Views**: New MainAppView, HomeFeedView, and improved detail views
- 🔄 **Better State Management**: Improved data flow and error handling
- 📖 **Responsive Reading**: Added support for call-and-response bulletin sections
- 🏗️ **Rust Backend**: Leveraging church_core library for consistent, performant API calls
- 📊 **Improved Performance**: Faster data loading and better memory management
- 🔗 **Unified Platform**: Same backend as website and Beacon for data consistency
### Version 1.2.1 ### Version 1.2.1
- Improved Bible verse formatting in splash screen - Improved Bible verse formatting in splash screen
- Removed verse numbers
- Removed paragraph markers
- Cleaned up parenthetical content
- Better text formatting
- Enhanced bulletin view formatting - Enhanced bulletin view formatting
- Improved header detection and styling - Better text processing and presentation
- Better section organization
- Consistent spacing and alignment
- Cleaner text presentation
### Version 1.2 ### Version 1.2
- Added Digital Bulletin system - Added Digital Bulletin system
- View weekly church bulletins - Interactive hymn and Bible verse links
- Interactive hymn links - PDF download functionality
- Bible verse links - Updated to iOS 17.0 minimum
- PDF download option
- Updated minimum iOS version to 17.0
- Updated Xcode version requirement to 15.0
### Version 1.1 ### Version 1.1
- Initial release - Initial release
- Live streaming - Basic live streaming and media library
- Sermon library - Church information and beliefs reference
- Church information
- Beliefs reference
## License ## License
This project is licensed under the MIT License - see the LICENSE file for details. This project is licensed under the GNU General Public License v3.0 - see the LICENSE file for details.
**Important Note**: While the app code is GPL v3, the church content (sermons, bulletins, media) remains copyrighted by the Rockville-Tolland Seventh-day Adventist Church. See LICENSE file for full details.
## Contact ## Contact

8
RTSDA-Bridging-Header.h Normal file
View file

@ -0,0 +1,8 @@
//
// RTSDA-Bridging-Header.h
// RTSDA
//
// Use this file to import your target's public headers that you would like to expose to Swift.
//
#import "church_coreFFI.h"

View file

@ -4,5 +4,11 @@
<dict> <dict>
<key>aps-environment</key> <key>aps-environment</key>
<string>development</string> <string>development</string>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.personal-information.calendars</key>
<true/>
</dict> </dict>
</plist> </plist>

View file

@ -7,59 +7,50 @@
objects = { objects = {
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
EA019ECF2D978167002FC58F /* BulletinViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA019ECE2D978167002FC58F /* BulletinViewModel.swift */; };
EA1C83A42D43EA4900D8B78F /* SermonBrowserViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1C838F2D43EA4900D8B78F /* SermonBrowserViewModel.swift */; };
EA1C83A52D43EA4900D8B78F /* RTSDAApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1C83702D43EA4900D8B78F /* RTSDAApp.swift */; }; EA1C83A52D43EA4900D8B78F /* RTSDAApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1C83702D43EA4900D8B78F /* RTSDAApp.swift */; };
EA1C83A62D43EA4900D8B78F /* EventsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1C838C2D43EA4900D8B78F /* EventsViewModel.swift */; };
EA1C83A72D43EA4900D8B78F /* LivestreamCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1C839A2D43EA4900D8B78F /* LivestreamCard.swift */; };
EA1C83A92D43EA4900D8B78F /* Event.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1C83622D43EA4900D8B78F /* Event.swift */; };
EA1C83AA2D43EA4900D8B78F /* MessagesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1C839C2D43EA4900D8B78F /* MessagesView.swift */; };
EA1C83AB2D43EA4900D8B78F /* EventsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1C83982D43EA4900D8B78F /* EventsView.swift */; };
EA1C83AC2D43EA4900D8B78F /* EventDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1C83972D43EA4900D8B78F /* EventDetailView.swift */; };
EA1C83AD2D43EA4900D8B78F /* CachedAsyncImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1C83932D43EA4900D8B78F /* CachedAsyncImage.swift */; };
EA1C83AE2D43EA4900D8B78F /* ImageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1C83792D43EA4900D8B78F /* ImageCache.swift */; };
EA1C83AF2D43EA4900D8B78F /* OwncastView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1C839D2D43EA4900D8B78F /* OwncastView.swift */; };
EA1C83B02D43EA4900D8B78F /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1C83952D43EA4900D8B78F /* ContentView.swift */; };
EA1C83B12D43EA4900D8B78F /* Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1C83612D43EA4900D8B78F /* Config.swift */; };
EA1C83B22D43EA4900D8B78F /* OwncastViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1C838E2D43EA4900D8B78F /* OwncastViewModel.swift */; };
EA1C83B32D43EA4900D8B78F /* PocketBaseService.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1C837C2D43EA4900D8B78F /* PocketBaseService.swift */; };
EA1C83B42D43EA4900D8B78F /* ContactFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1C83942D43EA4900D8B78F /* ContactFormView.swift */; };
EA1C83B52D43EA4900D8B78F /* JellyfinService.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1C837A2D43EA4900D8B78F /* JellyfinService.swift */; };
EA1C83B62D43EA4900D8B78F /* EventCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1C83962D43EA4900D8B78F /* EventCard.swift */; };
EA1C83B72D43EA4900D8B78F /* ConfigService.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1C83782D43EA4900D8B78F /* ConfigService.swift */; };
EA1C83B82D43EA4900D8B78F /* MessageCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1C839B2D43EA4900D8B78F /* MessageCard.swift */; };
EA1C83B92D43EA4900D8B78F /* Sermon.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1C83642D43EA4900D8B78F /* Sermon.swift */; };
EA1C83BA2D43EA4900D8B78F /* PermissionsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1C835F2D43EA4900D8B78F /* PermissionsManager.swift */; }; EA1C83BA2D43EA4900D8B78F /* PermissionsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1C835F2D43EA4900D8B78F /* PermissionsManager.swift */; };
EA1C83BC2D43EA4900D8B78F /* BeliefsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1C83912D43EA4900D8B78F /* BeliefsView.swift */; };
EA1C83BD2D43EA4900D8B78F /* BulletinView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1C83922D43EA4900D8B78F /* BulletinView.swift */; };
EA1C83BE2D43EA4900D8B78F /* Message.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1C83632D43EA4900D8B78F /* Message.swift */; };
EA1C83BF2D43EA4900D8B78F /* BibleService.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1C83772D43EA4900D8B78F /* BibleService.swift */; };
EA1C83C02D43EA4900D8B78F /* Color+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1C835C2D43EA4900D8B78F /* Color+Extensions.swift */; }; EA1C83C02D43EA4900D8B78F /* Color+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1C835C2D43EA4900D8B78F /* Color+Extensions.swift */; };
EA1C83C12D43EA4900D8B78F /* VideoPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1C839F2D43EA4900D8B78F /* VideoPlayerView.swift */; };
EA1C83C22D43EA4900D8B78F /* MessagesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1C838D2D43EA4900D8B78F /* MessagesViewModel.swift */; };
EA1C83C32D43EA4900D8B78F /* OwnCastService.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1C837B2D43EA4900D8B78F /* OwnCastService.swift */; };
EA1C83C42D43EA4900D8B78F /* SplashScreenView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1C839E2D43EA4900D8B78F /* SplashScreenView.swift */; };
EA1C83C52D43EA4900D8B78F /* JellyfinPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1C83992D43EA4900D8B78F /* JellyfinPlayerView.swift */; };
EA1C83C62D43EA4900D8B78F /* AppAvailabilityService.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1C83762D43EA4900D8B78F /* AppAvailabilityService.swift */; };
EA1C83C72D43EA4900D8B78F /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = EA1C83662D43EA4900D8B78F /* Preview Assets.xcassets */; }; EA1C83C72D43EA4900D8B78F /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = EA1C83662D43EA4900D8B78F /* Preview Assets.xcassets */; };
EA1C83C92D43EA4900D8B78F /* Lora-Italic.ttf in Resources */ = {isa = PBXBuildFile; fileRef = EA1C83682D43EA4900D8B78F /* Lora-Italic.ttf */; }; EA1C83C92D43EA4900D8B78F /* Lora-Italic.ttf in Resources */ = {isa = PBXBuildFile; fileRef = EA1C83682D43EA4900D8B78F /* Lora-Italic.ttf */; };
EA1C83CD2D43EA4900D8B78F /* Lora-Regular.ttf in Resources */ = {isa = PBXBuildFile; fileRef = EA1C83692D43EA4900D8B78F /* Lora-Regular.ttf */; }; EA1C83CD2D43EA4900D8B78F /* Lora-Regular.ttf in Resources */ = {isa = PBXBuildFile; fileRef = EA1C83692D43EA4900D8B78F /* Lora-Regular.ttf */; };
EA1C83CE2D43EA4900D8B78F /* Montserrat-SemiBold.ttf in Resources */ = {isa = PBXBuildFile; fileRef = EA1C836B2D43EA4900D8B78F /* Montserrat-SemiBold.ttf */; }; EA1C83CE2D43EA4900D8B78F /* Montserrat-SemiBold.ttf in Resources */ = {isa = PBXBuildFile; fileRef = EA1C836B2D43EA4900D8B78F /* Montserrat-SemiBold.ttf */; };
EA1C83CF2D43EA4900D8B78F /* Montserrat-Regular.ttf in Resources */ = {isa = PBXBuildFile; fileRef = EA1C836A2D43EA4900D8B78F /* Montserrat-Regular.ttf */; }; EA1C83CF2D43EA4900D8B78F /* Montserrat-Regular.ttf in Resources */ = {isa = PBXBuildFile; fileRef = EA1C836A2D43EA4900D8B78F /* Montserrat-Regular.ttf */; };
EA1C83D22D43EA4900D8B78F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = EA1C835B2D43EA4900D8B78F /* Assets.xcassets */; }; EA1C83D22D43EA4900D8B78F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = EA1C835B2D43EA4900D8B78F /* Assets.xcassets */; };
EA24699E2E222470004A9D9F /* church_core.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA24699D2E222470004A9D9F /* church_core.swift */; };
EA2469C52E22EE2A004A9D9F /* ChurchDataService.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA2469C32E22EE2A004A9D9F /* ChurchDataService.swift */; };
EA2469E42E22EEEE004A9D9F /* VideoPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA2469DE2E22EEEE004A9D9F /* VideoPlayerView.swift */; };
EA2469E52E22EEEE004A9D9F /* MainAppView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA2469DC2E22EEEE004A9D9F /* MainAppView.swift */; };
EA2469E92E22EEEE004A9D9F /* SplashScreenView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA2469DD2E22EEEE004A9D9F /* SplashScreenView.swift */; };
EA2469EA2E22EEEE004A9D9F /* ContactFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA2469DB2E22EEEE004A9D9F /* ContactFormView.swift */; };
EA2469EB2E22EEEE004A9D9F /* CachedAsyncImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA2469DA2E22EEEE004A9D9F /* CachedAsyncImage.swift */; };
EA2469EC2E22EEEE004A9D9F /* FeedItemCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA2469C72E22EEEE004A9D9F /* FeedItemCard.swift */; };
EA2469F02E22EEEE004A9D9F /* ContentCards.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA2469C62E22EEEE004A9D9F /* ContentCards.swift */; };
EA2469F32E22EEEE004A9D9F /* BeliefsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA2469D92E22EEEE004A9D9F /* BeliefsView.swift */; };
EA2469F52E22EEEE004A9D9F /* BulletinDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA2469C92E22EEEE004A9D9F /* BulletinDetailView.swift */; };
EA2469F82E22EFC3004A9D9F /* ContentModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA2469F62E22EFC3004A9D9F /* ContentModels.swift */; };
EA246A042E2349B8004A9D9F /* EventsListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA246A032E2349B8004A9D9F /* EventsListView.swift */; };
EA9A999F2E4413410039692E /* ServiceTimeRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA9A999E2E4413410039692E /* ServiceTimeRow.swift */; };
EA9A99A22E457CF00039692E /* EventDetailShared.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA9A99A12E457CF00039692E /* EventDetailShared.swift */; };
EA9A99A42E457CFB0039692E /* EventDetailView+iPad.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA9A99A32E457CFB0039692E /* EventDetailView+iPad.swift */; };
EA9A99A62E457D040039692E /* EventDetailView+iPhone.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA9A99A52E457D040039692E /* EventDetailView+iPhone.swift */; };
EA9A99A82E457D130039692E /* EventDetailViewWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA9A99A72E457D130039692E /* EventDetailViewWrapper.swift */; };
EA9A99AC2E4802EE0039692E /* ChurchCore.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = EA9A99AB2E4802EE0039692E /* ChurchCore.xcframework */; };
EA9A99B42E482DD50039692E /* SharedVideoPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA9A99B32E482DD50039692E /* SharedVideoPlayerView.swift */; };
EA9A99B72E4969DA0039692E /* BulletinsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA9A99B62E4969DA0039692E /* BulletinsView.swift */; };
EA9A99B92E496A910039692E /* WatchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA9A99B82E496A910039692E /* WatchView.swift */; };
EA9A99BB2E496B3E0039692E /* ConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA9A99BA2E496B3E0039692E /* ConnectView.swift */; };
EA9A99BD2E496C030039692E /* HomeFeedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA9A99BC2E496C030039692E /* HomeFeedView.swift */; };
EA9A99BF2E496C0A0039692E /* FilterSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA9A99BE2E496C0A0039692E /* FilterSheet.swift */; };
EA9A99C12E496CC70039692E /* ScriptureComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA9A99C02E496CC70039692E /* ScriptureComponents.swift */; };
EAA1DEC12E4BAE27001414BF /* ContactActionRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAA1DEC02E4BAE27001414BF /* ContactActionRow.swift */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
EA019ECE2D978167002FC58F /* BulletinViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BulletinViewModel.swift; sourceTree = "<group>"; };
EA07AAFA2D43EF78002ACBF8 /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = Platforms/iPhoneOS.platform/Developer/Library/Frameworks/XCTest.framework; sourceTree = DEVELOPER_DIR; }; EA07AAFA2D43EF78002ACBF8 /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = Platforms/iPhoneOS.platform/Developer/Library/Frameworks/XCTest.framework; sourceTree = DEVELOPER_DIR; };
EA1C835B2D43EA4900D8B78F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; EA1C835B2D43EA4900D8B78F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
EA1C835C2D43EA4900D8B78F /* Color+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+Extensions.swift"; sourceTree = "<group>"; }; EA1C835C2D43EA4900D8B78F /* Color+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+Extensions.swift"; sourceTree = "<group>"; };
EA1C835E2D43EA4900D8B78F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; EA1C835E2D43EA4900D8B78F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
EA1C835F2D43EA4900D8B78F /* PermissionsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionsManager.swift; sourceTree = "<group>"; }; EA1C835F2D43EA4900D8B78F /* PermissionsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionsManager.swift; sourceTree = "<group>"; };
EA1C83612D43EA4900D8B78F /* Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Config.swift; sourceTree = "<group>"; };
EA1C83622D43EA4900D8B78F /* Event.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Event.swift; sourceTree = "<group>"; };
EA1C83632D43EA4900D8B78F /* Message.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Message.swift; sourceTree = "<group>"; };
EA1C83642D43EA4900D8B78F /* Sermon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sermon.swift; sourceTree = "<group>"; };
EA1C83662D43EA4900D8B78F /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; }; EA1C83662D43EA4900D8B78F /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
EA1C83682D43EA4900D8B78F /* Lora-Italic.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Lora-Italic.ttf"; sourceTree = "<group>"; }; EA1C83682D43EA4900D8B78F /* Lora-Italic.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Lora-Italic.ttf"; sourceTree = "<group>"; };
EA1C83692D43EA4900D8B78F /* Lora-Regular.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Lora-Regular.ttf"; sourceTree = "<group>"; }; EA1C83692D43EA4900D8B78F /* Lora-Regular.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Lora-Regular.ttf"; sourceTree = "<group>"; };
@ -68,33 +59,36 @@
EA1C836E2D43EA4900D8B78F /* RTSDA.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RTSDA.entitlements; sourceTree = "<group>"; }; EA1C836E2D43EA4900D8B78F /* RTSDA.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RTSDA.entitlements; sourceTree = "<group>"; };
EA1C836F2D43EA4900D8B78F /* RTSDA.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; path = RTSDA.xcodeproj; sourceTree = "<group>"; }; EA1C836F2D43EA4900D8B78F /* RTSDA.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; path = RTSDA.xcodeproj; sourceTree = "<group>"; };
EA1C83702D43EA4900D8B78F /* RTSDAApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RTSDAApp.swift; sourceTree = "<group>"; }; EA1C83702D43EA4900D8B78F /* RTSDAApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RTSDAApp.swift; sourceTree = "<group>"; };
EA1C83762D43EA4900D8B78F /* AppAvailabilityService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppAvailabilityService.swift; sourceTree = "<group>"; }; EA24699D2E222470004A9D9F /* church_core.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = church_core.swift; sourceTree = "<group>"; };
EA1C83772D43EA4900D8B78F /* BibleService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BibleService.swift; sourceTree = "<group>"; }; EA2469B52E22DDD4004A9D9F /* libchurch_core.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libchurch_core.a; sourceTree = "<group>"; };
EA1C83782D43EA4900D8B78F /* ConfigService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigService.swift; sourceTree = "<group>"; }; EA2469C32E22EE2A004A9D9F /* ChurchDataService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChurchDataService.swift; sourceTree = "<group>"; };
EA1C83792D43EA4900D8B78F /* ImageCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCache.swift; sourceTree = "<group>"; }; EA2469C62E22EEEE004A9D9F /* ContentCards.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentCards.swift; sourceTree = "<group>"; };
EA1C837A2D43EA4900D8B78F /* JellyfinService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JellyfinService.swift; sourceTree = "<group>"; }; EA2469C72E22EEEE004A9D9F /* FeedItemCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedItemCard.swift; sourceTree = "<group>"; };
EA1C837B2D43EA4900D8B78F /* OwnCastService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OwnCastService.swift; sourceTree = "<group>"; }; EA2469C92E22EEEE004A9D9F /* BulletinDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BulletinDetailView.swift; sourceTree = "<group>"; };
EA1C837C2D43EA4900D8B78F /* PocketBaseService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PocketBaseService.swift; sourceTree = "<group>"; }; EA2469D92E22EEEE004A9D9F /* BeliefsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BeliefsView.swift; sourceTree = "<group>"; };
EA1C838C2D43EA4900D8B78F /* EventsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventsViewModel.swift; sourceTree = "<group>"; }; EA2469DA2E22EEEE004A9D9F /* CachedAsyncImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CachedAsyncImage.swift; sourceTree = "<group>"; };
EA1C838D2D43EA4900D8B78F /* MessagesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessagesViewModel.swift; sourceTree = "<group>"; }; EA2469DB2E22EEEE004A9D9F /* ContactFormView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactFormView.swift; sourceTree = "<group>"; };
EA1C838E2D43EA4900D8B78F /* OwncastViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OwncastViewModel.swift; sourceTree = "<group>"; }; EA2469DC2E22EEEE004A9D9F /* MainAppView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainAppView.swift; sourceTree = "<group>"; };
EA1C838F2D43EA4900D8B78F /* SermonBrowserViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SermonBrowserViewModel.swift; sourceTree = "<group>"; }; EA2469DD2E22EEEE004A9D9F /* SplashScreenView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplashScreenView.swift; sourceTree = "<group>"; };
EA1C83912D43EA4900D8B78F /* BeliefsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BeliefsView.swift; sourceTree = "<group>"; }; EA2469DE2E22EEEE004A9D9F /* VideoPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerView.swift; sourceTree = "<group>"; };
EA1C83922D43EA4900D8B78F /* BulletinView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BulletinView.swift; sourceTree = "<group>"; }; EA2469F62E22EFC3004A9D9F /* ContentModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentModels.swift; sourceTree = "<group>"; };
EA1C83932D43EA4900D8B78F /* CachedAsyncImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CachedAsyncImage.swift; sourceTree = "<group>"; }; EA246A032E2349B8004A9D9F /* EventsListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventsListView.swift; sourceTree = "<group>"; };
EA1C83942D43EA4900D8B78F /* ContactFormView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactFormView.swift; sourceTree = "<group>"; };
EA1C83952D43EA4900D8B78F /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
EA1C83962D43EA4900D8B78F /* EventCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventCard.swift; sourceTree = "<group>"; };
EA1C83972D43EA4900D8B78F /* EventDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventDetailView.swift; sourceTree = "<group>"; };
EA1C83982D43EA4900D8B78F /* EventsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventsView.swift; sourceTree = "<group>"; };
EA1C83992D43EA4900D8B78F /* JellyfinPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JellyfinPlayerView.swift; sourceTree = "<group>"; };
EA1C839A2D43EA4900D8B78F /* LivestreamCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LivestreamCard.swift; sourceTree = "<group>"; };
EA1C839B2D43EA4900D8B78F /* MessageCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageCard.swift; sourceTree = "<group>"; };
EA1C839C2D43EA4900D8B78F /* MessagesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessagesView.swift; sourceTree = "<group>"; };
EA1C839D2D43EA4900D8B78F /* OwncastView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OwncastView.swift; sourceTree = "<group>"; };
EA1C839E2D43EA4900D8B78F /* SplashScreenView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplashScreenView.swift; sourceTree = "<group>"; };
EA1C839F2D43EA4900D8B78F /* VideoPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerView.swift; sourceTree = "<group>"; };
EA2F9F7E2CF406E800B9F454 /* RTSDA.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = RTSDA.app; sourceTree = BUILT_PRODUCTS_DIR; }; EA2F9F7E2CF406E800B9F454 /* RTSDA.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = RTSDA.app; sourceTree = BUILT_PRODUCTS_DIR; };
EA3401352E42FC4C006B90C6 /* libchurch_core_tvos.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libchurch_core_tvos.a; sourceTree = "<group>"; };
EA9A999E2E4413410039692E /* ServiceTimeRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceTimeRow.swift; sourceTree = "<group>"; };
EA9A99A12E457CF00039692E /* EventDetailShared.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventDetailShared.swift; sourceTree = "<group>"; };
EA9A99A32E457CFB0039692E /* EventDetailView+iPad.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EventDetailView+iPad.swift"; sourceTree = "<group>"; };
EA9A99A52E457D040039692E /* EventDetailView+iPhone.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EventDetailView+iPhone.swift"; sourceTree = "<group>"; };
EA9A99A72E457D130039692E /* EventDetailViewWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventDetailViewWrapper.swift; sourceTree = "<group>"; };
EA9A99AB2E4802EE0039692E /* ChurchCore.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = ChurchCore.xcframework; sourceTree = "<group>"; };
EA9A99B32E482DD50039692E /* SharedVideoPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedVideoPlayerView.swift; sourceTree = "<group>"; };
EA9A99B62E4969DA0039692E /* BulletinsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BulletinsView.swift; sourceTree = "<group>"; };
EA9A99B82E496A910039692E /* WatchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchView.swift; sourceTree = "<group>"; };
EA9A99BA2E496B3E0039692E /* ConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectView.swift; sourceTree = "<group>"; };
EA9A99BC2E496C030039692E /* HomeFeedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeFeedView.swift; sourceTree = "<group>"; };
EA9A99BE2E496C0A0039692E /* FilterSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterSheet.swift; sourceTree = "<group>"; };
EA9A99C02E496CC70039692E /* ScriptureComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScriptureComponents.swift; sourceTree = "<group>"; };
EAA1DEC02E4BAE27001414BF /* ContactActionRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactActionRow.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
@ -102,6 +96,7 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
EA9A99AC2E4802EE0039692E /* ChurchCore.xcframework in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@ -111,6 +106,7 @@
EA07AAF92D43EF78002ACBF8 /* Frameworks */ = { EA07AAF92D43EF78002ACBF8 /* Frameworks */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
EA3401352E42FC4C006B90C6 /* libchurch_core_tvos.a */,
EA07AAFA2D43EF78002ACBF8 /* XCTest.framework */, EA07AAFA2D43EF78002ACBF8 /* XCTest.framework */,
); );
name = Frameworks; name = Frameworks;
@ -132,17 +128,6 @@
path = Managers; path = Managers;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
EA1C83652D43EA4900D8B78F /* Models */ = {
isa = PBXGroup;
children = (
EA1C83612D43EA4900D8B78F /* Config.swift */,
EA1C83622D43EA4900D8B78F /* Event.swift */,
EA1C83632D43EA4900D8B78F /* Message.swift */,
EA1C83642D43EA4900D8B78F /* Sermon.swift */,
);
path = Models;
sourceTree = "<group>";
};
EA1C83672D43EA4900D8B78F /* Preview Content */ = { EA1C83672D43EA4900D8B78F /* Preview Content */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@ -170,77 +155,93 @@
path = Resources; path = Resources;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
EA1C837D2D43EA4900D8B78F /* Services */ = { EA1C83A12D43EA4900D8B78F /* Products */ = {
isa = PBXGroup;
name = Products;
sourceTree = "<group>";
};
EA2469C42E22EE2A004A9D9F /* Services */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
EA1C83762D43EA4900D8B78F /* AppAvailabilityService.swift */, EA2469C32E22EE2A004A9D9F /* ChurchDataService.swift */,
EA1C83772D43EA4900D8B78F /* BibleService.swift */,
EA1C83782D43EA4900D8B78F /* ConfigService.swift */,
EA1C83792D43EA4900D8B78F /* ImageCache.swift */,
EA1C837A2D43EA4900D8B78F /* JellyfinService.swift */,
EA1C837B2D43EA4900D8B78F /* OwnCastService.swift */,
EA1C837C2D43EA4900D8B78F /* PocketBaseService.swift */,
); );
path = Services; path = Services;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
EA1C83902D43EA4900D8B78F /* ViewModels */ = { EA2469C82E22EEEE004A9D9F /* Components */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
EA1C838C2D43EA4900D8B78F /* EventsViewModel.swift */, EA2469C62E22EEEE004A9D9F /* ContentCards.swift */,
EA1C838D2D43EA4900D8B78F /* MessagesViewModel.swift */, EA2469C72E22EEEE004A9D9F /* FeedItemCard.swift */,
EA1C838E2D43EA4900D8B78F /* OwncastViewModel.swift */, EA9A999E2E4413410039692E /* ServiceTimeRow.swift */,
EA1C838F2D43EA4900D8B78F /* SermonBrowserViewModel.swift */, EA9A99C02E496CC70039692E /* ScriptureComponents.swift */,
EA019ECE2D978167002FC58F /* BulletinViewModel.swift */, EAA1DEC02E4BAE27001414BF /* ContactActionRow.swift */,
); );
path = ViewModels; path = Components;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
EA1C83A02D43EA4900D8B78F /* Views */ = { EA2469CC2E22EEEE004A9D9F /* Detail */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
EA1C83912D43EA4900D8B78F /* BeliefsView.swift */, EA2469C92E22EEEE004A9D9F /* BulletinDetailView.swift */,
EA1C83922D43EA4900D8B78F /* BulletinView.swift */, EA9A99A12E457CF00039692E /* EventDetailShared.swift */,
EA1C83932D43EA4900D8B78F /* CachedAsyncImage.swift */, EA9A99A32E457CFB0039692E /* EventDetailView+iPad.swift */,
EA1C83942D43EA4900D8B78F /* ContactFormView.swift */, EA9A99A52E457D040039692E /* EventDetailView+iPhone.swift */,
EA1C83952D43EA4900D8B78F /* ContentView.swift */, EA9A99A72E457D130039692E /* EventDetailViewWrapper.swift */,
EA1C83962D43EA4900D8B78F /* EventCard.swift */, );
EA1C83972D43EA4900D8B78F /* EventDetailView.swift */, path = Detail;
EA1C83982D43EA4900D8B78F /* EventsView.swift */, sourceTree = "<group>";
EA1C83992D43EA4900D8B78F /* JellyfinPlayerView.swift */, };
EA1C839A2D43EA4900D8B78F /* LivestreamCard.swift */, EA2469DF2E22EEEE004A9D9F /* Views */ = {
EA1C839B2D43EA4900D8B78F /* MessageCard.swift */, isa = PBXGroup;
EA1C839C2D43EA4900D8B78F /* MessagesView.swift */, children = (
EA1C839D2D43EA4900D8B78F /* OwncastView.swift */, EA2469C82E22EEEE004A9D9F /* Components */,
EA1C839E2D43EA4900D8B78F /* SplashScreenView.swift */, EA2469CC2E22EEEE004A9D9F /* Detail */,
EA1C839F2D43EA4900D8B78F /* VideoPlayerView.swift */, EA2469D92E22EEEE004A9D9F /* BeliefsView.swift */,
EA2469DA2E22EEEE004A9D9F /* CachedAsyncImage.swift */,
EA2469DB2E22EEEE004A9D9F /* ContactFormView.swift */,
EA2469DC2E22EEEE004A9D9F /* MainAppView.swift */,
EA2469DD2E22EEEE004A9D9F /* SplashScreenView.swift */,
EA2469DE2E22EEEE004A9D9F /* VideoPlayerView.swift */,
EA246A032E2349B8004A9D9F /* EventsListView.swift */,
EA9A99B32E482DD50039692E /* SharedVideoPlayerView.swift */,
EA9A99B62E4969DA0039692E /* BulletinsView.swift */,
EA9A99B82E496A910039692E /* WatchView.swift */,
EA9A99BA2E496B3E0039692E /* ConnectView.swift */,
EA9A99BC2E496C030039692E /* HomeFeedView.swift */,
EA9A99BE2E496C0A0039692E /* FilterSheet.swift */,
); );
path = Views; path = Views;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
EA1C83A12D43EA4900D8B78F /* Products */ = { EA2469F72E22EFC3004A9D9F /* Models */ = {
isa = PBXGroup; isa = PBXGroup;
name = Products; children = (
EA2469F62E22EFC3004A9D9F /* ContentModels.swift */,
);
path = Models;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
EA2F9F752CF406E800B9F454 = { EA2F9F752CF406E800B9F454 = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
EA1C835B2D43EA4900D8B78F /* Assets.xcassets */, EA1C835B2D43EA4900D8B78F /* Assets.xcassets */,
EA2469F72E22EFC3004A9D9F /* Models */,
EA1C835D2D43EA4900D8B78F /* Extensions */, EA1C835D2D43EA4900D8B78F /* Extensions */,
EA1C835E2D43EA4900D8B78F /* Info.plist */, EA1C835E2D43EA4900D8B78F /* Info.plist */,
EA1C83602D43EA4900D8B78F /* Managers */, EA1C83602D43EA4900D8B78F /* Managers */,
EA1C83652D43EA4900D8B78F /* Models */,
EA1C83672D43EA4900D8B78F /* Preview Content */, EA1C83672D43EA4900D8B78F /* Preview Content */,
EA1C836D2D43EA4900D8B78F /* Resources */, EA1C836D2D43EA4900D8B78F /* Resources */,
EA1C836E2D43EA4900D8B78F /* RTSDA.entitlements */, EA1C836E2D43EA4900D8B78F /* RTSDA.entitlements */,
EA2469C42E22EE2A004A9D9F /* Services */,
EA1C836F2D43EA4900D8B78F /* RTSDA.xcodeproj */, EA1C836F2D43EA4900D8B78F /* RTSDA.xcodeproj */,
EA1C83702D43EA4900D8B78F /* RTSDAApp.swift */, EA1C83702D43EA4900D8B78F /* RTSDAApp.swift */,
EA1C837D2D43EA4900D8B78F /* Services */,
EA1C83902D43EA4900D8B78F /* ViewModels */,
EA1C83A02D43EA4900D8B78F /* Views */,
EA07AAF92D43EF78002ACBF8 /* Frameworks */, EA07AAF92D43EF78002ACBF8 /* Frameworks */,
EA2F9F7F2CF406E800B9F454 /* Products */, EA2F9F7F2CF406E800B9F454 /* Products */,
EA24699D2E222470004A9D9F /* church_core.swift */,
EA2469B52E22DDD4004A9D9F /* libchurch_core.a */,
EA2469DF2E22EEEE004A9D9F /* Views */,
EA9A99AB2E4802EE0039692E /* ChurchCore.xcframework */,
); );
sourceTree = "<group>"; sourceTree = "<group>";
}; };
@ -282,7 +283,7 @@
attributes = { attributes = {
BuildIndependentTargetsInParallel = 1; BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1610; LastSwiftUpdateCheck = 1610;
LastUpgradeCheck = 1630; LastUpgradeCheck = 2600;
TargetAttributes = { TargetAttributes = {
EA2F9F7D2CF406E800B9F454 = { EA2F9F7D2CF406E800B9F454 = {
CreatedOnToolsVersion = 16.1; CreatedOnToolsVersion = 16.1;
@ -337,40 +338,35 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
EA1C83A42D43EA4900D8B78F /* SermonBrowserViewModel.swift in Sources */,
EA1C83A52D43EA4900D8B78F /* RTSDAApp.swift in Sources */, EA1C83A52D43EA4900D8B78F /* RTSDAApp.swift in Sources */,
EA1C83A62D43EA4900D8B78F /* EventsViewModel.swift in Sources */, EA9A99BB2E496B3E0039692E /* ConnectView.swift in Sources */,
EA1C83A72D43EA4900D8B78F /* LivestreamCard.swift in Sources */, EA2469F82E22EFC3004A9D9F /* ContentModels.swift in Sources */,
EA1C83A92D43EA4900D8B78F /* Event.swift in Sources */, EA9A99B92E496A910039692E /* WatchView.swift in Sources */,
EA1C83AA2D43EA4900D8B78F /* MessagesView.swift in Sources */,
EA1C83AB2D43EA4900D8B78F /* EventsView.swift in Sources */,
EA1C83AC2D43EA4900D8B78F /* EventDetailView.swift in Sources */,
EA1C83AD2D43EA4900D8B78F /* CachedAsyncImage.swift in Sources */,
EA1C83AE2D43EA4900D8B78F /* ImageCache.swift in Sources */,
EA1C83AF2D43EA4900D8B78F /* OwncastView.swift in Sources */,
EA1C83B02D43EA4900D8B78F /* ContentView.swift in Sources */,
EA1C83B12D43EA4900D8B78F /* Config.swift in Sources */,
EA1C83B22D43EA4900D8B78F /* OwncastViewModel.swift in Sources */,
EA1C83B32D43EA4900D8B78F /* PocketBaseService.swift in Sources */,
EA1C83B42D43EA4900D8B78F /* ContactFormView.swift in Sources */,
EA1C83B52D43EA4900D8B78F /* JellyfinService.swift in Sources */,
EA1C83B62D43EA4900D8B78F /* EventCard.swift in Sources */,
EA1C83B72D43EA4900D8B78F /* ConfigService.swift in Sources */,
EA1C83B82D43EA4900D8B78F /* MessageCard.swift in Sources */,
EA1C83B92D43EA4900D8B78F /* Sermon.swift in Sources */,
EA1C83BA2D43EA4900D8B78F /* PermissionsManager.swift in Sources */, EA1C83BA2D43EA4900D8B78F /* PermissionsManager.swift in Sources */,
EA1C83BC2D43EA4900D8B78F /* BeliefsView.swift in Sources */, EA9A99B42E482DD50039692E /* SharedVideoPlayerView.swift in Sources */,
EA1C83BD2D43EA4900D8B78F /* BulletinView.swift in Sources */, EA9A99A62E457D040039692E /* EventDetailView+iPhone.swift in Sources */,
EA1C83BE2D43EA4900D8B78F /* Message.swift in Sources */, EA9A99A22E457CF00039692E /* EventDetailShared.swift in Sources */,
EA1C83BF2D43EA4900D8B78F /* BibleService.swift in Sources */, EA2469C52E22EE2A004A9D9F /* ChurchDataService.swift in Sources */,
EA1C83C02D43EA4900D8B78F /* Color+Extensions.swift in Sources */, EA1C83C02D43EA4900D8B78F /* Color+Extensions.swift in Sources */,
EA1C83C12D43EA4900D8B78F /* VideoPlayerView.swift in Sources */, EA9A999F2E4413410039692E /* ServiceTimeRow.swift in Sources */,
EA1C83C22D43EA4900D8B78F /* MessagesViewModel.swift in Sources */, EA9A99A82E457D130039692E /* EventDetailViewWrapper.swift in Sources */,
EA1C83C32D43EA4900D8B78F /* OwnCastService.swift in Sources */, EA2469E42E22EEEE004A9D9F /* VideoPlayerView.swift in Sources */,
EA1C83C42D43EA4900D8B78F /* SplashScreenView.swift in Sources */, EA2469E52E22EEEE004A9D9F /* MainAppView.swift in Sources */,
EA019ECF2D978167002FC58F /* BulletinViewModel.swift in Sources */, EAA1DEC12E4BAE27001414BF /* ContactActionRow.swift in Sources */,
EA1C83C52D43EA4900D8B78F /* JellyfinPlayerView.swift in Sources */, EA246A042E2349B8004A9D9F /* EventsListView.swift in Sources */,
EA1C83C62D43EA4900D8B78F /* AppAvailabilityService.swift in Sources */, EA2469E92E22EEEE004A9D9F /* SplashScreenView.swift in Sources */,
EA2469EA2E22EEEE004A9D9F /* ContactFormView.swift in Sources */,
EA2469EB2E22EEEE004A9D9F /* CachedAsyncImage.swift in Sources */,
EA2469EC2E22EEEE004A9D9F /* FeedItemCard.swift in Sources */,
EA9A99C12E496CC70039692E /* ScriptureComponents.swift in Sources */,
EA2469F02E22EEEE004A9D9F /* ContentCards.swift in Sources */,
EA9A99BF2E496C0A0039692E /* FilterSheet.swift in Sources */,
EA2469F32E22EEEE004A9D9F /* BeliefsView.swift in Sources */,
EA2469F52E22EEEE004A9D9F /* BulletinDetailView.swift in Sources */,
EA9A99BD2E496C030039692E /* HomeFeedView.swift in Sources */,
EA9A99B72E4969DA0039692E /* BulletinsView.swift in Sources */,
EA9A99A42E457CFB0039692E /* EventDetailView+iPad.swift in Sources */,
EA24699E2E222470004A9D9F /* church_core.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@ -437,8 +433,10 @@
NEW_SETTING = ""; NEW_SETTING = "";
ONLY_ACTIVE_ARCH = YES; ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos; SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_PRECOMPILE_BRIDGING_HEADER = NO;
}; };
name = Debug; name = Debug;
}; };
@ -495,7 +493,9 @@
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
NEW_SETTING = ""; NEW_SETTING = "";
SDKROOT = iphoneos; SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_COMPILATION_MODE = wholemodule; SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_PRECOMPILE_BRIDGING_HEADER = NO;
VALIDATE_PRODUCT = YES; VALIDATE_PRODUCT = YES;
}; };
name = Release; name = Release;
@ -509,6 +509,7 @@
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "\"Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"Preview Content\"";
DEVELOPMENT_TEAM = TQMND62F2W;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Info.plist; INFOPLIST_FILE = Info.plist;
@ -532,10 +533,19 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
LIBRARY_SEARCH_PATHS = (
"$(inherited)",
"$(PROJECT_DIR)",
);
MARKETING_VERSION = 2.0; MARKETING_VERSION = 2.0;
PRODUCT_BUNDLE_IDENTIFIER = com.rtsda.appr; PRODUCT_BUNDLE_IDENTIFIER = com.rtsda.appr;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = YES;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OBJC_BRIDGING_HEADER = "RTSDA-Bridging-Header.h";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
SYSTEM_FRAMEWORK_SEARCH_PATHS = ""; SYSTEM_FRAMEWORK_SEARCH_PATHS = "";
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";
@ -551,6 +561,7 @@
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "\"Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"Preview Content\"";
DEVELOPMENT_TEAM = TQMND62F2W;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Info.plist; INFOPLIST_FILE = Info.plist;
@ -574,10 +585,19 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
LIBRARY_SEARCH_PATHS = (
"$(inherited)",
"$(PROJECT_DIR)",
);
MARKETING_VERSION = 2.0; MARKETING_VERSION = 2.0;
PRODUCT_BUNDLE_IDENTIFIER = com.rtsda.appr; PRODUCT_BUNDLE_IDENTIFIER = com.rtsda.appr;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = YES;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OBJC_BRIDGING_HEADER = "RTSDA-Bridging-Header.h";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
SYSTEM_FRAMEWORK_SEARCH_PATHS = ""; SYSTEM_FRAMEWORK_SEARCH_PATHS = "";
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";

View file

@ -9,20 +9,12 @@ import SwiftUI
@main @main
struct RTSDAApp: App { struct RTSDAApp: App {
@StateObject private var configService = ConfigService.shared @State private var dataService = ChurchDataService()
init() {
// Enable standard orientations (portrait and landscape)
if #available(iOS 16.0, *) {
UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.forEach { windowScene in
windowScene.requestGeometryUpdate(.iOS(interfaceOrientations: [.portrait, .landscapeLeft, .landscapeRight]))
}
}
}
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
SplashScreenView() SplashScreenView()
.environment(dataService)
} }
} }
} }

View file

@ -0,0 +1,44 @@
# Rust Crate Improvements Needed
## HTML Tag Stripping in Scripture Readings
**Issue**: Scripture readings contain HTML tags that appear in the iOS UI.
**Solution**: Update the Rust crate to strip HTML tags from scripture_reading fields in the ClientBulletin model.
**Suggested Implementation**:
```rust
// In src/models/client_models.rs - ClientBulletin::from() implementation
impl From<Bulletin> for ClientBulletin {
fn from(bulletin: Bulletin) -> Self {
Self {
// ... other fields
scripture_reading: strip_html_tags(&bulletin.scripture_reading),
// ... other fields
}
}
}
// Helper function to strip HTML tags
fn strip_html_tags(text: &str) -> String {
// Use a regex or HTML parsing library to remove HTML tags
// This could use the `html2text` crate that's already a dependency
html2text::from_read(text.as_bytes(), 80)
.replace('\n', " ")
.split_whitespace()
.collect::<Vec<&str>>()
.join(" ")
}
```
**Benefits**:
- Clean data for all client platforms
- Consistent text rendering
- Better performance (processed once on server)
- Easier maintenance
**Files to Update**:
- `src/models/client_models.rs`
- Possibly `src/models/bulletin.rs` if the cleaning should happen earlier
**Priority**: Medium - affects user experience but doesn't break functionality

View file

@ -1,178 +0,0 @@
import Foundation
import UIKit
class AppAvailabilityService {
static let shared = AppAvailabilityService()
private var availabilityCache: [String: Bool] = [:]
// Common URL schemes
struct Schemes {
static let bible = "youversion://"
static let egw = "egw-ios://"
static let hymnal = "adventisthymnarium://"
static let sabbathSchool = "com.googleusercontent.apps.443920152945-d0kf5h2dubt0jbcntq8l0qeg6lbpgn60://"
static let sabbathSchoolAlt = "https://sabbath-school.adventech.io"
static let egwWritingsWeb = "https://m.egwwritings.org/en/folders/2"
static let facebook = "https://www.facebook.com/rockvilletollandsdachurch/"
static let tiktok = "https://www.tiktok.com/@rockvilletollandsda"
static let spotify = "spotify://show/2ARQaUBaGnVTiF9syrKDvO"
static let podcasts = "podcasts://podcasts.apple.com/us/podcast/rockville-tolland-sda-church/id1630777684"
}
// App Store fallback URLs
struct AppStoreURLs {
static let sabbathSchool = "https://apps.apple.com/us/app/sabbath-school/id895272167"
static let egwWritings = "https://apps.apple.com/us/app/egw-writings-2/id994076136"
static let egwWritingsWeb = "https://m.egwwritings.org/en/folders/2"
static let hymnal = "https://apps.apple.com/us/app/hymnal-adventist/id6738877733"
static let bible = "https://apps.apple.com/us/app/bible/id282935706"
static let facebook = "https://apps.apple.com/us/app/facebook/id284882215"
static let tiktok = "https://apps.apple.com/us/app/tiktok/id835599320"
static let spotify = "https://apps.apple.com/us/app/spotify-music-and-podcasts/id324684580"
static let podcasts = "https://apps.apple.com/us/app/apple-podcasts/id525463029"
}
private init() {
// Check for common apps at launch
checkAvailability(urlScheme: Schemes.sabbathSchool)
checkAvailability(urlScheme: Schemes.egw)
checkAvailability(urlScheme: Schemes.bible)
checkAvailability(urlScheme: Schemes.hymnal)
checkAvailability(urlScheme: Schemes.facebook)
checkAvailability(urlScheme: Schemes.tiktok)
checkAvailability(urlScheme: Schemes.spotify)
checkAvailability(urlScheme: Schemes.podcasts)
}
func isAppInstalled(urlScheme: String) -> Bool {
if let cached = availabilityCache[urlScheme] {
return cached
}
return checkAvailability(urlScheme: urlScheme)
}
@discardableResult
private func checkAvailability(urlScheme: String) -> Bool {
guard let url = URL(string: urlScheme) else {
print("⚠️ Failed to create URL for scheme: \(urlScheme)")
return false
}
let isAvailable = UIApplication.shared.canOpenURL(url)
print("📱 App availability for \(urlScheme): \(isAvailable)")
availabilityCache[urlScheme] = isAvailable
return isAvailable
}
func openApp(urlScheme: String, fallbackURL: String) {
// First try the URL scheme
if let appUrl = URL(string: urlScheme) {
print("🔗 Attempting to open URL: \(appUrl)")
// Check if we can open the URL
if UIApplication.shared.canOpenURL(appUrl) {
print("✅ Opening app URL: \(appUrl)")
UIApplication.shared.open(appUrl) { success in
if !success {
print("❌ Failed to open app URL: \(appUrl)")
self.handleFallback(urlScheme: urlScheme, fallbackURL: fallbackURL)
}
}
} else {
print("❌ Cannot open app URL: \(appUrl)")
handleFallback(urlScheme: urlScheme, fallbackURL: fallbackURL)
}
} else {
print("⚠️ Failed to create URL: \(urlScheme)")
handleFallback(urlScheme: urlScheme, fallbackURL: fallbackURL)
}
}
// Open a specific hymn by number
func openHymnByNumber(_ hymnNumber: Int) {
// Format: adventisthymnarium://hymn?number=123
let hymnalScheme = "\(Schemes.hymnal)hymn?number=\(hymnNumber)"
print("🎵 Attempting to open hymn #\(hymnNumber): \(hymnalScheme)")
if let hymnUrl = URL(string: hymnalScheme) {
if UIApplication.shared.canOpenURL(hymnUrl) {
UIApplication.shared.open(hymnUrl) { success in
if !success {
print("❌ Failed to open hymn #\(hymnNumber)")
self.openApp(urlScheme: Schemes.hymnal, fallbackURL: AppStoreURLs.hymnal)
}
}
} else {
print("❌ Cannot open hymn #\(hymnNumber)")
openApp(urlScheme: Schemes.hymnal, fallbackURL: AppStoreURLs.hymnal)
}
} else {
print("⚠️ Failed to create URL for hymn #\(hymnNumber)")
openApp(urlScheme: Schemes.hymnal, fallbackURL: AppStoreURLs.hymnal)
}
}
// Open a specific responsive reading by number
func openResponsiveReadingByNumber(_ readingNumber: Int) {
// Format: adventisthymnarium://reading?number=123
let hymnalScheme = "\(Schemes.hymnal)reading?number=\(readingNumber)"
print("📖 Attempting to open responsive reading #\(readingNumber): \(hymnalScheme)")
if let readingUrl = URL(string: hymnalScheme) {
if UIApplication.shared.canOpenURL(readingUrl) {
UIApplication.shared.open(readingUrl) { success in
if !success {
print("❌ Failed to open responsive reading #\(readingNumber)")
self.openApp(urlScheme: Schemes.hymnal, fallbackURL: AppStoreURLs.hymnal)
}
}
} else {
print("❌ Cannot open responsive reading #\(readingNumber)")
openApp(urlScheme: Schemes.hymnal, fallbackURL: AppStoreURLs.hymnal)
}
} else {
print("⚠️ Failed to create URL for responsive reading #\(readingNumber)")
openApp(urlScheme: Schemes.hymnal, fallbackURL: AppStoreURLs.hymnal)
}
}
private func handleFallback(urlScheme: String, fallbackURL: String) {
// Try the App Store URL first
if let fallback = URL(string: fallbackURL) {
print("⬇️ Opening App Store: \(fallback)")
UIApplication.shared.open(fallback) { success in
if !success {
print("❌ Failed to open App Store URL: \(fallback)")
// Handle web fallbacks if App Store fails
if urlScheme == Schemes.egw {
if let webUrl = URL(string: Schemes.egwWritingsWeb) {
print("✅ Opening EGW mobile web URL: \(webUrl)")
UIApplication.shared.open(webUrl)
}
} else if urlScheme == Schemes.sabbathSchool {
if let altUrl = URL(string: Schemes.sabbathSchoolAlt) {
print("✅ Opening Sabbath School web app: \(altUrl)")
UIApplication.shared.open(altUrl)
}
}
}
}
} else {
print("❌ Failed to create App Store URL: \(fallbackURL)")
// Handle web fallbacks if App Store URL is invalid
if urlScheme == Schemes.egw {
if let webUrl = URL(string: Schemes.egwWritingsWeb) {
print("✅ Opening EGW mobile web URL: \(webUrl)")
UIApplication.shared.open(webUrl)
}
} else if urlScheme == Schemes.sabbathSchool {
if let altUrl = URL(string: Schemes.sabbathSchoolAlt) {
print("✅ Opening Sabbath School web app: \(altUrl)")
UIApplication.shared.open(altUrl)
}
}
}
}
}

View file

@ -1,158 +0,0 @@
import Foundation
@MainActor
class BibleService {
static let shared = BibleService()
private let pocketBaseService = PocketBaseService.shared
private init() {}
struct Verse: Identifiable, Codable {
let id: String
let reference: String
let text: String
let isActive: Bool
enum CodingKeys: String, CodingKey {
case id
case reference
case text
case isActive = "is_active"
}
}
struct VersesRecord: Codable {
let collectionId: String
let collectionName: String
let created: String
let id: String
let updated: String
let verses: VersesData
struct VersesData: Codable {
let id: String
let verses: [Verse]
}
}
private var cachedVerses: [Verse]?
func getRandomVerse() async throws -> (verse: String, reference: String) {
let verses = try await getVerses()
print("Total verses available: \(verses.count)")
guard !verses.isEmpty else {
print("No verses available")
throw NSError(domain: "BibleService", code: -1, userInfo: [NSLocalizedDescriptionKey: "No verses available"])
}
let randomVerse = verses.randomElement()!
print("Selected random verse: \(randomVerse.reference)")
return (verse: randomVerse.text, reference: randomVerse.reference)
}
func getVerse(reference: String) async throws -> (verse: String, reference: String) {
print("Looking up verse with reference: \(reference)")
// Convert API-style reference (e.g., "JER.29.11") to display format ("Jeremiah 29:11")
let displayReference = reference
.replacingOccurrences(of: "\\.", with: " ", options: .regularExpression)
.replacingOccurrences(of: "([A-Z]+)", with: "$1 ", options: .regularExpression)
.trimmingCharacters(in: .whitespaces)
print("Converted reference to: \(displayReference)")
let verses = try await getVerses()
print("Found \(verses.count) verses")
if let verse = verses.first(where: { $0.reference.lowercased() == displayReference.lowercased() }) {
print("Found matching verse: \(verse.reference)")
return (verse: verse.text, reference: verse.reference)
}
print("No matching verse found for reference: \(displayReference)")
throw NSError(domain: "BibleService", code: -1, userInfo: [NSLocalizedDescriptionKey: "Verse not found"])
}
private func getVerses() async throws -> [Verse] {
// Return cached verses if available
if let cached = cachedVerses {
print("Returning cached verses")
return cached
}
print("Fetching verses from PocketBase")
// Fetch from PocketBase
let endpoint = "\(PocketBaseService.shared.baseURL)/bible_verses/records/nkf01o1q3456flr"
guard let url = URL(string: endpoint) else {
print("Invalid URL: \(endpoint)")
throw URLError(.badURL)
}
var request = URLRequest(url: url)
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
print("Making request to: \(endpoint)")
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
print("Invalid response type")
throw URLError(.badServerResponse)
}
print("Response status code: \(httpResponse.statusCode)")
guard httpResponse.statusCode == 200 else {
if let errorString = String(data: data, encoding: .utf8) {
print("Error response from server: \(errorString)")
}
throw URLError(.badServerResponse)
}
// Print raw response for debugging
if let rawResponse = String(data: data, encoding: .utf8) {
print("Raw response from PocketBase:")
print(rawResponse)
}
let decoder = JSONDecoder()
do {
let versesRecord = try decoder.decode(VersesRecord.self, from: data)
// Cache the verses
cachedVerses = versesRecord.verses.verses
print("Successfully fetched and cached \(versesRecord.verses.verses.count) verses")
return versesRecord.verses.verses
} catch {
print("Failed to decode response: \(error)")
if let decodingError = error as? DecodingError {
switch decodingError {
case .keyNotFound(let key, let context):
print("Missing key: \(key.stringValue) in \(context.debugDescription)")
case .typeMismatch(let type, let context):
print("Type mismatch: expected \(type) in \(context.debugDescription)")
case .valueNotFound(let type, let context):
print("Value not found: expected \(type) in \(context.debugDescription)")
case .dataCorrupted(let context):
print("Data corrupted: \(context.debugDescription)")
@unknown default:
print("Unknown decoding error")
}
}
throw error
}
}
func testAllVerses() async throws {
print("\n=== Testing All Verses ===\n")
let verses = try await getVerses()
for verse in verses {
print("Reference: \(verse.reference)")
print("Verse: \(verse.text)")
print("-------------------\n")
}
print("=== Test Complete ===\n")
}
}

View file

@ -1,68 +0,0 @@
import Foundation
class BulletinService {
static let shared = BulletinService()
private let pocketBaseService = PocketBaseService.shared
private init() {}
func getBulletins(activeOnly: Bool = true) async throws -> [Bulletin] {
var urlString = "\(pocketBaseService.baseURL)/api/collections/bulletins/records?sort=-date"
if activeOnly {
urlString += "&filter=(is_active=true)"
}
guard let url = URL(string: urlString) else {
throw URLError(.badURL)
}
var request = URLRequest(url: url)
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode) else {
throw URLError(.badServerResponse)
}
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
struct BulletinResponse: Codable {
let items: [Bulletin]
}
let bulletinResponse = try decoder.decode(BulletinResponse.self, from: data)
return bulletinResponse.items
}
func getBulletin(id: String) async throws -> Bulletin {
let urlString = "\(pocketBaseService.baseURL)/api/collections/bulletins/records/\(id)"
guard let url = URL(string: urlString) else {
throw URLError(.badURL)
}
var request = URLRequest(url: url)
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode) else {
throw URLError(.badServerResponse)
}
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return try decoder.decode(Bulletin.self, from: data)
}
func getLatestBulletin() async throws -> Bulletin? {
let bulletins = try await getBulletins(activeOnly: true)
return bulletins.first
}
}

View file

@ -0,0 +1,403 @@
import Foundation
import Observation
// MARK: - Media Type Enum
enum MediaType: String, CaseIterable {
case sermons = "sermons"
case livestreams = "livestreams"
var displayName: String {
switch self {
case .sermons:
return "Sermons"
case .livestreams:
return "Live Archives"
}
}
var icon: String {
switch self {
case .sermons:
return "play.rectangle.fill"
case .livestreams:
return "dot.radiowaves.left.and.right"
}
}
}
@Observable
class ChurchDataService {
// MARK: - Properties
// State
var isLoading = false
var error: Error?
// Content
var events: [ChurchEvent] = []
var sermons: [Sermon] = []
var livestreamArchives: [Sermon] = []
var bulletins: [ChurchBulletin] = []
var dailyVerse: BibleVerse?
// Stream status
var isStreamLive = false
// Media Type Selection
var currentMediaType: MediaType = .sermons
// Feed
var feedItems: [FeedItem] = []
// MARK: - Public Methods
/// Load all content for the home feed
func loadHomeFeed() async {
await withTaskGroup(of: Void.self) { group in
group.addTask { await self.loadEvents() }
group.addTask { await self.loadRecentSermons() }
group.addTask { await self.loadRecentBulletins() }
group.addTask { await self.loadDailyVerse() }
group.addTask { await self.loadStreamStatus() }
}
await updateFeed()
}
/// Load upcoming events (for home feed - limited)
func loadEvents() async {
do {
isLoading = true
error = nil
let eventsJson = fetchEventsJson()
// Use Rust function for JSON parsing - NO business logic in Swift!
let eventsArrayJson = parseEventsFromJson(eventsJson: eventsJson)
let data = eventsArrayJson.data(using: .utf8) ?? Data()
let allEvents = try JSONDecoder().decode([ChurchEvent].self, from: data)
await MainActor.run {
// For home feed, limit to 5 events
self.events = Array(allEvents.prefix(5))
self.isLoading = false
}
} catch {
print("❌ Failed to load events: \(error)")
await MainActor.run {
self.error = error
self.isLoading = false
}
}
}
/// Load all events (for events list view)
func loadAllEvents() async {
do {
isLoading = true
error = nil
let eventsJson = fetchEventsJson()
// Use Rust function for JSON parsing - NO business logic in Swift!
let eventsArrayJson = parseEventsFromJson(eventsJson: eventsJson)
let data = eventsArrayJson.data(using: .utf8) ?? Data()
let allEvents = try JSONDecoder().decode([ChurchEvent].self, from: data)
await MainActor.run {
self.events = allEvents
self.isLoading = false
}
} catch {
print("❌ Failed to load all events: \(error)")
await MainActor.run {
self.error = error
self.isLoading = false
}
}
}
/// Load recent sermons
func loadRecentSermons() async {
do {
let sermonsJson = fetchSermonsJson()
// Use Rust function for JSON parsing - NO business logic in Swift!
let validatedJson = parseSermonsFromJson(sermonsJson: sermonsJson)
let data = validatedJson.data(using: .utf8) ?? Data()
let sermonList = try JSONDecoder().decode([Sermon].self, from: data)
await MainActor.run {
// Get the 5 most recent sermons
self.sermons = Array(sermonList.prefix(5))
}
} catch {
await MainActor.run {
self.error = error
}
}
}
/// Load all sermons (for Watch tab)
func loadAllSermons() async {
do {
isLoading = true
error = nil
let sermonsJson = fetchSermonsJson()
// Use Rust function for JSON parsing - NO business logic in Swift!
let validatedJson = parseSermonsFromJson(sermonsJson: sermonsJson)
let data = validatedJson.data(using: .utf8) ?? Data()
let sermonList = try JSONDecoder().decode([Sermon].self, from: data)
await MainActor.run {
self.sermons = sermonList
self.isLoading = false
}
} catch {
print("❌ Failed to load sermons: \(error)")
await MainActor.run {
self.error = error
self.isLoading = false
}
}
}
/// Load all livestream archives (for Watch tab)
func loadAllLivestreamArchives() async {
do {
isLoading = true
error = nil
let archivesJson = fetchLivestreamArchiveJson()
// Use Rust function for JSON parsing - NO business logic in Swift!
let validatedJson = parseSermonsFromJson(sermonsJson: archivesJson)
let data = validatedJson.data(using: .utf8) ?? Data()
let archiveList = try JSONDecoder().decode([Sermon].self, from: data)
await MainActor.run {
self.livestreamArchives = archiveList
self.isLoading = false
}
} catch {
print("❌ Failed to load livestream archives: \(error)")
await MainActor.run {
self.error = error
self.isLoading = false
}
}
}
/// Load content based on current media type
func loadCurrentMediaType() async {
switch currentMediaType {
case .sermons:
await loadAllSermons()
case .livestreams:
await loadAllLivestreamArchives()
}
}
/// Switch media type and load appropriate content
func switchMediaType(to newType: MediaType) async {
await MainActor.run {
self.currentMediaType = newType
}
await loadCurrentMediaType()
}
/// Get current content based on media type
var currentContent: [Sermon] {
switch currentMediaType {
case .sermons:
return sermons
case .livestreams:
return livestreamArchives
}
}
/// Load recent bulletins
func loadRecentBulletins() async {
do {
let bulletinsJson = fetchBulletinsJson()
// Use Rust function for JSON parsing - NO business logic in Swift!
let validatedJson = parseBulletinsFromJson(bulletinsJson: bulletinsJson)
let data = validatedJson.data(using: .utf8) ?? Data()
let bulletinList = try JSONDecoder().decode([ChurchBulletin].self, from: data)
await MainActor.run {
// Get the 3 most recent bulletins
self.bulletins = Array(bulletinList.prefix(3))
}
} catch {
await MainActor.run {
self.error = error
}
}
}
/// Load all bulletins (for Discover tab)
func loadAllBulletins() async {
do {
isLoading = true
error = nil
let bulletinsJson = fetchBulletinsJson()
// Use Rust function for JSON parsing - NO business logic in Swift!
let validatedJson = parseBulletinsFromJson(bulletinsJson: bulletinsJson)
let data = validatedJson.data(using: .utf8) ?? Data()
let bulletinList = try JSONDecoder().decode([ChurchBulletin].self, from: data)
await MainActor.run {
self.bulletins = bulletinList
self.isLoading = false
}
} catch {
await MainActor.run {
self.error = error
self.isLoading = false
}
}
}
/// Load daily Bible verse
func loadDailyVerse() async {
do {
let verseJson = fetchRandomBibleVerseJson()
// Use Rust function for JSON parsing - NO business logic in Swift!
let validatedJson = parseBibleVerseFromJson(verseJson: verseJson)
let data = validatedJson.data(using: .utf8) ?? Data()
let verse = try JSONDecoder().decode(BibleVerse.self, from: data)
await MainActor.run {
self.dailyVerse = verse
}
} catch {
print("❌ Failed to load daily verse: \(error)")
print("📖 Raw JSON was: \(fetchRandomBibleVerseJson())")
}
}
/// Search Bible verses
func searchVerses(_ query: String) async -> [BibleVerse] {
do {
let versesJson = fetchBibleVerseJson(query: query)
// Use Rust function for JSON parsing - NO business logic in Swift!
let validatedJson = parseBibleVerseFromJson(verseJson: versesJson)
let data = validatedJson.data(using: .utf8) ?? Data()
return try JSONDecoder().decode([BibleVerse].self, from: data)
} catch {
print("Failed to search verses: \(error)")
return []
}
}
/// Load stream status to check if live
func loadStreamStatus() async {
// Use Rust function directly - NO JSON parsing in Swift!
// Note: Using simple extraction until getStreamLiveStatus() binding is available
let statusJson = fetchStreamStatusJson()
let isLive = statusJson.contains("\"is_live\":true")
await MainActor.run {
self.isStreamLive = isLive
}
}
/// Submit contact form
func submitContact(name: String, email: String, subject: String, message: String, phone: String = "") async -> Bool {
let resultJson = submitContactV2Json(
name: name,
email: email,
subject: subject,
message: message,
phone: phone
)
// Use Rust function for JSON parsing - NO business logic in Swift!
let validatedJson = parseContactResultFromJson(resultJson: resultJson)
let data = validatedJson.data(using: .utf8) ?? Data()
do {
let result = try JSONDecoder().decode(ContactSubmissionResult.self, from: data)
return result.success
} catch {
return false
}
}
// MARK: - Private Methods
/// Create unified feed from all content
private func updateFeed() async {
await MainActor.run {
guard let eventsJson = try? JSONEncoder().encode(events),
let sermonsJson = try? JSONEncoder().encode(sermons),
let bulletinsJson = try? JSONEncoder().encode(bulletins),
let verseJson = try? JSONEncoder().encode(dailyVerse),
let eventsJsonString = String(data: eventsJson, encoding: .utf8),
let sermonsJsonString = String(data: sermonsJson, encoding: .utf8),
let bulletinsJsonString = String(data: bulletinsJson, encoding: .utf8),
let verseJsonString = String(data: verseJson, encoding: .utf8) else {
// Simple fallback - empty feed
self.feedItems = []
return
}
let feedJson = generateHomeFeedJson(
eventsJson: eventsJsonString,
sermonsJson: sermonsJsonString,
bulletinsJson: bulletinsJsonString,
verseJson: verseJsonString
)
guard let data = feedJson.data(using: .utf8),
let rustFeedItems = try? JSONDecoder().decode([RustFeedItem].self, from: data) else {
// Simple fallback - empty feed
self.feedItems = []
return
}
// Convert to Swift FeedItem models
self.feedItems = rustFeedItems.compactMap { rustItem -> FeedItem? in
let timestamp = parseDate(rustItem.timestamp) ?? Date()
switch rustItem.feedType {
case .event(let event):
return FeedItem(type: .event(event), timestamp: timestamp)
case .sermon(let sermon):
return FeedItem(type: .sermon(sermon), timestamp: timestamp)
case .bulletin(let bulletin):
return FeedItem(type: .bulletin(bulletin), timestamp: timestamp)
case .verse(let verse):
return FeedItem(type: .verse(verse), timestamp: timestamp)
}
}
}
}
private func parseDate(_ dateString: String) -> Date? {
let formatter = ISO8601DateFormatter()
return formatter.date(from: dateString)
}
}
// MARK: - Singleton Access
extension ChurchDataService {
static let shared = ChurchDataService()
}

View file

@ -1,63 +0,0 @@
import Foundation
@MainActor
class ConfigService: ObservableObject {
static let shared = ConfigService()
private let pocketBaseService = PocketBaseService.shared
@Published private(set) var config: Config?
@Published private(set) var error: Error?
@Published private(set) var isLoading = false
private init() {}
var bibleApiKey: String? {
config?.apiKeys.bibleApiKey
}
var jellyfinApiKey: String? {
config?.apiKeys.jellyfinApiKey
}
var churchName: String {
config?.churchName ?? "Rockville-Tolland SDA Church"
}
var aboutText: String {
config?.aboutText ?? ""
}
var contactEmail: String {
config?.contactEmail ?? "av@rockvilletollandsda.org"
}
var contactPhone: String {
config?.contactPhone ?? "8608750450"
}
var churchAddress: String {
config?.churchAddress ?? "9 Hartford Tpke Tolland CT 06084"
}
var googleMapsUrl: String {
config?.googleMapsUrl ?? "https://maps.app.goo.gl/Ld4YZFPQGxGRBFJt8"
}
func loadConfig() async {
isLoading = true
error = nil
do {
config = try await pocketBaseService.fetchConfig()
} catch {
self.error = error
print("Failed to load app configuration: \(error)")
}
isLoading = false
}
func refreshConfig() async {
await loadConfig()
}
}

View file

@ -1,23 +0,0 @@
import SwiftUI
actor ImageCache {
static let shared = ImageCache()
private let cache: NSCache<NSString, UIImage> = {
let cache = NSCache<NSString, UIImage>()
cache.countLimit = 100 // Maximum number of images to cache
return cache
}()
private init() {}
func image(for url: URL) -> UIImage? {
let key = url.absoluteString as NSString
return cache.object(forKey: key)
}
func setImage(_ image: UIImage, for url: URL) {
let key = url.absoluteString as NSString
cache.setObject(image, forKey: key)
}
}

View file

@ -1,280 +0,0 @@
import Foundation
@MainActor
class JellyfinService {
static let shared = JellyfinService()
private let configService = ConfigService.shared
private let baseUrl = "https://jellyfin.rockvilletollandsda.church"
private var apiKey: String? {
configService.jellyfinApiKey
}
private var libraryId: String?
private var currentType: MediaType = .sermons
enum MediaType: String {
case sermons = "Sermons"
case livestreams = "LiveStreams"
}
enum JellyfinError: Error {
case invalidURL
case networkError
case decodingError
case noVideosFound
case libraryNotFound
}
struct JellyfinItem: Codable {
let id: String
let name: String
let tags: [String]?
let premiereDate: String?
let productionYear: Int?
let overview: String?
let mediaType: String
let type: String
let path: String
let dateCreated: String
enum CodingKeys: String, CodingKey {
case id = "Id"
case name = "Name"
case tags = "Tags"
case premiereDate = "PremiereDate"
case productionYear = "ProductionYear"
case overview = "Overview"
case mediaType = "MediaType"
case type = "Type"
case path = "Path"
case dateCreated = "DateCreated"
}
}
private struct LibraryResponse: Codable {
let items: [Library]
enum CodingKeys: String, CodingKey {
case items = "Items"
}
}
private struct Library: Codable {
let id: String
let name: String
let path: String
enum CodingKeys: String, CodingKey {
case id = "Id"
case name = "Name"
case path = "Path"
}
}
private struct ItemsResponse: Codable {
let items: [JellyfinItem]
enum CodingKeys: String, CodingKey {
case items = "Items"
}
}
private init() {}
func setType(_ type: MediaType) {
self.currentType = type
self.libraryId = nil // Reset library ID when switching types
}
private func fetchWithAuth(_ url: URL) async throws -> (Data, URLResponse) {
// Ensure config is loaded
if configService.config == nil {
await configService.loadConfig()
}
guard let apiKey = self.apiKey else {
throw JellyfinError.networkError
}
var request = URLRequest(url: url)
request.timeoutInterval = 30
request.addValue(apiKey, forHTTPHeaderField: "X-MediaBrowser-Token")
request.addValue("MediaBrowser Client=\"RTSDA iOS\", Device=\"iOS\", DeviceId=\"rtsda-ios\", Version=\"1.0.0\", Token=\"\(apiKey)\"",
forHTTPHeaderField: "X-Emby-Authorization")
do {
let (data, response) = try await URLSession.shared.data(for: request)
// Check if task was cancelled
try Task.checkCancellation()
guard let httpResponse = response as? HTTPURLResponse else {
throw JellyfinError.networkError
}
guard (200...299).contains(httpResponse.statusCode) else {
if let responseString = String(data: data, encoding: .utf8) {
print("Jellyfin API error: \(responseString)")
}
throw JellyfinError.networkError
}
return (data, response)
} catch is CancellationError {
throw URLError(.cancelled)
} catch {
print("Network request failed: \(error.localizedDescription)")
throw error
}
}
private func getLibraryId() async throws -> String {
if let id = libraryId { return id }
let url = URL(string: "\(baseUrl)/Library/MediaFolders")!
do {
let (data, _) = try await fetchWithAuth(url)
// Check if task was cancelled
try Task.checkCancellation()
let response = try JSONDecoder().decode(LibraryResponse.self, from: data)
let searchTerm = currentType == .sermons ? "Sermons" : "LiveStreams"
// Try exact match on name
if let library = response.items.first(where: { $0.name == searchTerm }) {
libraryId = library.id
return library.id
}
print("Library not found: \(searchTerm)")
print("Available libraries: \(response.items.map { $0.name }.joined(separator: ", "))")
throw JellyfinError.libraryNotFound
} catch is CancellationError {
throw URLError(.cancelled)
} catch {
print("Error fetching library ID: \(error.localizedDescription)")
throw error
}
}
private func parseDate(_ dateString: String) -> Date? {
// Create formatters for different possible formats
let formatters = [
{ () -> DateFormatter in
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSSZ" // PocketBase format
return formatter
}(),
{ () -> ISO8601DateFormatter in
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter
}(),
{ () -> ISO8601DateFormatter in
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime]
return formatter
}()
]
for formatter in formatters {
if let date = (formatter as? ISO8601DateFormatter)?.date(from: dateString) ??
(formatter as? DateFormatter)?.date(from: dateString) {
return date
}
}
return nil
}
func fetchSermons(type: SermonType) async throws -> [Sermon] {
currentType = MediaType(rawValue: type.rawValue)!
let libraryId = try await getLibraryId()
// Check if task was cancelled
try Task.checkCancellation()
let urlString = "\(baseUrl)/Items?ParentId=\(libraryId)&Fields=Path,PremiereDate,ProductionYear,Overview,DateCreated&Recursive=true&IncludeItemTypes=Movie,Video,Episode&SortBy=DateCreated&SortOrder=Descending"
guard let url = URL(string: urlString) else {
throw JellyfinError.invalidURL
}
let (data, _) = try await fetchWithAuth(url)
// Check if task was cancelled
try Task.checkCancellation()
let response = try JSONDecoder().decode(ItemsResponse.self, from: data)
return response.items.map { item in
var title = item.name
var speaker = "Unknown Speaker"
// Remove file extension if present
title = title.replacingOccurrences(of: #"\.(mp4|mov)$"#, with: "", options: .regularExpression)
// Try to split into title and speaker
let parts = title.components(separatedBy: " - ")
if parts.count > 1 {
title = parts[0].trimmingCharacters(in: .whitespaces)
let speakerPart = parts[1].trimmingCharacters(in: .whitespaces)
speaker = speakerPart.replacingOccurrences(
of: #"\s+(?:January|February|March|April|May|June|July|August|September|October|November|December)\s+\d+(?:th|st|nd|rd)?\s*\d{4}$"#,
with: "",
options: .regularExpression
).replacingOccurrences(of: "|", with: "").trimmingCharacters(in: .whitespaces)
}
// Parse date with UTC handling
let rawDate = item.premiereDate ?? item.dateCreated
let utcDate = parseDate(rawDate) ?? Date()
// Extract components in UTC and create a new date at noon UTC to avoid timezone issues
var calendar = Calendar.current
calendar.timeZone = TimeZone(identifier: "UTC")!
var components = calendar.dateComponents([.year, .month, .day], from: utcDate)
components.hour = 12 // Set to noon UTC to ensure date remains the same in all timezones
let localDate = calendar.date(from: components) ?? Date()
return Sermon(
id: item.id,
title: title,
description: item.overview ?? "",
date: localDate,
speaker: speaker,
type: type,
videoUrl: getStreamUrl(itemId: item.id),
thumbnail: getImageUrl(itemId: item.id)
)
}
}
private func getStreamUrl(itemId: String) -> String {
var components = URLComponents(string: "\(baseUrl)/Videos/\(itemId)/master.m3u8")!
guard let apiKey = self.apiKey else { return "" }
components.queryItems = [
URLQueryItem(name: "api_key", value: apiKey),
URLQueryItem(name: "MediaSourceId", value: itemId),
URLQueryItem(name: "TranscodingProtocol", value: "hls"),
URLQueryItem(name: "RequireAvc", value: "true"),
URLQueryItem(name: "MaxStreamingBitrate", value: "20000000"),
URLQueryItem(name: "VideoBitrate", value: "10000000"),
URLQueryItem(name: "AudioBitrate", value: "192000"),
URLQueryItem(name: "AudioCodec", value: "aac"),
URLQueryItem(name: "VideoCodec", value: "h264"),
URLQueryItem(name: "MaxAudioChannels", value: "2"),
URLQueryItem(name: "StartTimeTicks", value: "0"),
URLQueryItem(name: "SubtitleMethod", value: "Embed"),
URLQueryItem(name: "TranscodeReasons", value: "VideoCodecNotSupported")
]
return components.url!.absoluteString
}
private func getImageUrl(itemId: String) -> String {
guard let apiKey = self.apiKey else { return "" }
return "\(baseUrl)/Items/\(itemId)/Images/Primary?api_key=\(apiKey)"
}
}

View file

@ -1,78 +0,0 @@
import Foundation
class OwnCastService {
static let shared = OwnCastService()
private let baseUrl = "https://stream.rockvilletollandsda.church"
private init() {}
struct StreamStatus: Codable {
let online: Bool
let streamTitle: String?
let lastConnectTime: String?
let lastDisconnectTime: String?
let serverTime: String?
let versionNumber: String?
enum CodingKeys: String, CodingKey {
case online
case streamTitle = "name"
case lastConnectTime
case lastDisconnectTime
case serverTime
case versionNumber
}
}
func getStreamStatus() async throws -> StreamStatus {
guard let url = URL(string: "\(baseUrl)/api/status") else {
throw URLError(.badURL)
}
var request = URLRequest(url: url)
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw URLError(.badServerResponse)
}
guard (200...299).contains(httpResponse.statusCode) else {
if let responseString = String(data: data, encoding: .utf8) {
print("Stream status error: \(responseString)")
}
throw URLError(.badServerResponse)
}
do {
return try JSONDecoder().decode(StreamStatus.self, from: data)
} catch {
print("Failed to decode stream status: \(error)")
throw error
}
}
func createLivestreamMessage(from status: StreamStatus) -> Message {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd"
dateFormatter.timeZone = TimeZone(identifier: "America/New_York")
let formattedDate = dateFormatter.string(from: Date())
return Message(
id: UUID().uuidString,
title: status.streamTitle ?? "Live Stream",
description: "Watch our live stream",
speaker: "Live Stream",
videoUrl: "\(baseUrl)/hls/stream.m3u8",
thumbnailUrl: "\(baseUrl)/thumbnail.jpg",
duration: 0,
isLiveStream: true,
isPublished: true,
isDeleted: false,
liveBroadcastStatus: "live",
date: formattedDate
)
}
}

View file

@ -1,302 +0,0 @@
import Foundation
struct BulletinSection: Identifiable {
let id = UUID()
let title: String
let content: String
}
struct Bulletin: Identifiable, Codable {
let id: String
let collectionId: String
let title: String
let date: Date
let divineWorship: String
let sabbathSchool: String
let scriptureReading: String
let sunset: String
let pdf: String?
let isActive: Bool
let created: Date
let updated: Date
enum CodingKeys: String, CodingKey {
case id
case collectionId = "collectionId"
case title
case date
case divineWorship = "divine_worship"
case sabbathSchool = "sabbath_school"
case scriptureReading = "scripture_reading"
case sunset
case pdf
case isActive = "is_active"
case created
case updated
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(String.self, forKey: .id)
collectionId = try container.decode(String.self, forKey: .collectionId)
title = try container.decode(String.self, forKey: .title)
date = try container.decode(Date.self, forKey: .date)
divineWorship = try container.decode(String.self, forKey: .divineWorship)
sabbathSchool = try container.decode(String.self, forKey: .sabbathSchool)
scriptureReading = try container.decode(String.self, forKey: .scriptureReading)
sunset = try container.decode(String.self, forKey: .sunset)
pdf = try container.decodeIfPresent(String.self, forKey: .pdf)
isActive = try container.decode(Bool.self, forKey: .isActive)
created = try container.decode(Date.self, forKey: .created)
updated = try container.decode(Date.self, forKey: .updated)
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(collectionId, forKey: .collectionId)
try container.encode(title, forKey: .title)
try container.encode(date, forKey: .date)
try container.encode(divineWorship, forKey: .divineWorship)
try container.encode(sabbathSchool, forKey: .sabbathSchool)
try container.encode(scriptureReading, forKey: .scriptureReading)
try container.encode(sunset, forKey: .sunset)
try container.encodeIfPresent(pdf, forKey: .pdf)
try container.encode(isActive, forKey: .isActive)
try container.encode(created, forKey: .created)
try container.encode(updated, forKey: .updated)
}
// Computed property to get the PDF URL
var pdfUrl: String {
if let pdf = pdf {
return "https://pocketbase.rockvilletollandsda.church/api/files/\(collectionId)/\(id)/\(pdf)"
}
return ""
}
// Computed property to get formatted content
var content: String {
"""
Divine Worship
\(divineWorship)
Sabbath School
\(sabbathSchool)
Scripture Reading
\(scriptureReading)
Sunset Information
\(sunset)
"""
}
}
class PocketBaseService {
static let shared = PocketBaseService()
let baseURL = "https://pocketbase.rockvilletollandsda.church/api/collections"
private init() {}
private let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSSZ"
return formatter
}()
private let decoder: JSONDecoder = {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .custom { decoder in
let container = try decoder.singleValueContainer()
let dateString = try container.decode(String.self)
print("Attempting to decode date string: \(dateString)")
// Try ISO8601 first
if let date = ISO8601DateFormatter().date(from: dateString) {
print("Successfully decoded ISO8601 date")
return date
}
// Try various date formats
let formatters = [
"yyyy-MM-dd'T'HH:mm:ss.SSSZ",
"yyyy-MM-dd'T'HH:mm:ssZ",
"yyyy-MM-dd HH:mm:ss.SSSZ",
"yyyy-MM-dd HH:mm:ssZ",
"yyyy-MM-dd"
]
for format in formatters {
let formatter = DateFormatter()
formatter.dateFormat = format
if let date = formatter.date(from: dateString) {
print("Successfully decoded date with format: \(format)")
return date
}
}
print("Failed to decode date string with any format")
throw DecodingError.dataCorruptedError(
in: container,
debugDescription: "Date string '\(dateString)' does not match any expected format"
)
}
return decoder
}()
struct EventResponse: Codable {
let page: Int
let perPage: Int
let totalItems: Int
let totalPages: Int
let items: [Event]
enum CodingKeys: String, CodingKey {
case page
case perPage
case totalItems
case totalPages
case items
}
}
struct BulletinResponse: Codable {
let page: Int
let perPage: Int
let totalItems: Int
let totalPages: Int
let items: [Bulletin]
}
@MainActor
func fetchConfig() async throws -> Config {
let recordId = "nn753t8o2t1iupd"
let urlString = "\(baseURL)/config/records/\(recordId)"
guard let url = URL(string: urlString) else {
throw URLError(.badURL)
}
var request = URLRequest(url: url)
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw URLError(.badServerResponse)
}
guard httpResponse.statusCode == 200 else {
if let errorString = String(data: data, encoding: .utf8) {
print("Error fetching config: \(errorString)")
}
throw URLError(.badServerResponse)
}
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
do {
return try decoder.decode(Config.self, from: data)
} catch {
print("Failed to decode config: \(error)")
if let decodingError = error as? DecodingError {
switch decodingError {
case .keyNotFound(let key, let context):
print("Missing key: \(key.stringValue) in \(context.debugDescription)")
case .typeMismatch(let type, let context):
print("Type mismatch: expected \(type) in \(context.debugDescription)")
case .valueNotFound(let type, let context):
print("Value not found: expected \(type) in \(context.debugDescription)")
case .dataCorrupted(let context):
print("Data corrupted: \(context.debugDescription)")
@unknown default:
print("Unknown decoding error")
}
}
throw error
}
}
@MainActor
func fetchEvents() async throws -> [Event] {
guard let url = URL(string: "\(baseURL)/events/records?sort=start_time") else {
throw URLError(.badURL)
}
let (data, response) = try await URLSession.shared.data(from: url)
guard let httpResponse = response as? HTTPURLResponse else {
throw URLError(.badServerResponse)
}
guard httpResponse.statusCode == 200 else {
if let errorString = String(data: data, encoding: .utf8) {
print("Error fetching events: \(errorString)")
}
throw URLError(.badServerResponse)
}
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
do {
let eventResponse = try decoder.decode(EventResponse.self, from: data)
return eventResponse.items
} catch {
print("Failed to decode events: \(error)")
if let decodingError = error as? DecodingError {
switch decodingError {
case .keyNotFound(let key, let context):
print("Missing key: \(key.stringValue) in \(context.debugDescription)")
case .typeMismatch(let type, let context):
print("Type mismatch: expected \(type) in \(context.debugDescription)")
case .valueNotFound(let type, let context):
print("Value not found: expected \(type) in \(context.debugDescription)")
case .dataCorrupted(let context):
print("Data corrupted: \(context.debugDescription)")
@unknown default:
print("Unknown decoding error")
}
}
throw error
}
}
func fetchBulletins(activeOnly: Bool = false) async throws -> BulletinResponse {
let endpoint = "\(baseURL)/bulletins/records"
var components = URLComponents(string: endpoint)!
var queryItems = [URLQueryItem]()
if activeOnly {
queryItems.append(URLQueryItem(name: "filter", value: "is_active=true"))
}
queryItems.append(URLQueryItem(name: "sort", value: "-date"))
components.queryItems = queryItems
guard let url = components.url else {
throw URLError(.badURL)
}
let (data, response) = try await URLSession.shared.data(from: url)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw URLError(.badServerResponse)
}
// Debug: Print the JSON response
if let jsonString = String(data: data, encoding: .utf8) {
print("Received JSON response: \(jsonString)")
}
do {
return try decoder.decode(BulletinResponse.self, from: data)
} catch {
print("Failed to decode bulletins: \(error)")
throw error
}
}
}

View file

@ -1,87 +0,0 @@
import Foundation
import SwiftUI
@MainActor
class BulletinViewModel: ObservableObject {
@Published var latestBulletin: Bulletin?
@Published var bulletins: [Bulletin] = []
@Published var isLoading = false
@Published var error: Error?
private let pocketBaseService = PocketBaseService.shared
private var currentTask: Task<Void, Never>?
func loadLatestBulletin() async {
// Cancel any existing task
currentTask?.cancel()
// Create new task
currentTask = Task {
guard !Task.isCancelled else { return }
isLoading = true
error = nil
do {
let response = try await pocketBaseService.fetchBulletins(activeOnly: true)
if !Task.isCancelled {
if let bulletin = response.items.first {
print("Loaded bulletin with ID: \(bulletin.id)")
print("PDF field value: \(bulletin.pdf ?? "nil")")
print("Generated PDF URL: \(bulletin.pdfUrl)")
latestBulletin = bulletin
}
}
} catch {
if !Task.isCancelled {
print("Error loading bulletin: \(error)")
self.error = error
}
}
if !Task.isCancelled {
isLoading = false
}
}
// Wait for the task to complete
await currentTask?.value
}
@MainActor
func loadBulletins() async {
// Cancel any existing task
currentTask?.cancel()
// Create new task
currentTask = Task {
guard !Task.isCancelled else { return }
isLoading = true
error = nil
do {
let response = try await PocketBaseService.shared.fetchBulletins(activeOnly: true)
if !Task.isCancelled {
self.bulletins = response.items
}
} catch {
if !Task.isCancelled {
print("Error loading bulletins: \(error)")
self.error = error
}
}
if !Task.isCancelled {
isLoading = false
}
}
// Wait for the task to complete
await currentTask?.value
}
deinit {
currentTask?.cancel()
}
}

View file

@ -1,57 +0,0 @@
import SwiftUI
@MainActor
class EventsViewModel: ObservableObject {
@Published private(set) var events: [Event] = []
@Published private(set) var isLoading = false
@Published private(set) var error: Error?
private let pocketBaseService = PocketBaseService.shared
func loadEvents() async {
isLoading = true
error = nil
do {
// Get current time
let now = Date()
print("🕒 Current time: \(now)")
// Show all events that haven't ended yet
let allEvents = try await pocketBaseService.fetchEvents()
print("📋 Total events from PocketBase: \(allEvents.count)")
for event in allEvents {
print("🗓️ Event: \(event.title)")
print(" Start: \(event.startDate)")
print(" End: \(event.endDate)")
print(" Category: \(event.category.rawValue)")
print(" Recurring: \(event.reoccuring.rawValue)")
print(" Published: \(event.isPublished)")
}
events = allEvents
.filter { event in
// Subtract 5 hours from the current time to match the UTC offset
// Because PocketBase stores "5 AM Eastern" as "5 AM UTC"
// So when it's actually "10 AM UTC", PocketBase shows "5 AM UTC"
let utcOffset = -5 * 60 * 60 // -5 hours in seconds
let adjustedNow = now.addingTimeInterval(TimeInterval(utcOffset))
let willShow = event.endDate > adjustedNow
print(" Compare - Event: \(event.title)")
print(" End time: \(event.endDate)")
print(" Current time (adjusted to UTC): \(adjustedNow)")
print(" Will show: \(willShow)")
return willShow
}
.sorted { $0.startDate < $1.startDate }
print("✅ Filtered events count: \(events.count)")
} catch {
print("❌ Error loading events: \(error)")
self.error = error
}
isLoading = false
}
}

View file

@ -1,249 +0,0 @@
import Foundation
@MainActor
class MessagesViewModel: ObservableObject {
@Published var messages: [Message] = []
@Published var filteredMessages: [Message] = []
@Published var livestream: Message?
@Published var isLoading = false
@Published var error: Error?
@Published var availableYears: [String] = []
@Published var availableMonths: [String] = []
@Published var currentMediaType: JellyfinService.MediaType = .sermons
private let jellyfinService = JellyfinService.shared
private let owncastService = OwnCastService.shared
private var currentTask: Task<Void, Never>?
private var autoRefreshTask: Task<Void, Never>?
init() {
Task {
await loadContent()
startAutoRefresh()
}
}
deinit {
autoRefreshTask?.cancel()
}
private func startAutoRefresh() {
// Cancel any existing auto-refresh task
autoRefreshTask?.cancel()
// Create new auto-refresh task
autoRefreshTask = Task {
while !Task.isCancelled {
// Wait for 5 seconds
try? await Task.sleep(nanoseconds: 5 * 1_000_000_000)
// Check only livestream status
let streamStatus = try? await owncastService.getStreamStatus()
print("📺 Stream status: \(String(describing: streamStatus))")
if let status = streamStatus, status.online {
print("📺 Stream is online! Creating livestream message")
self.livestream = owncastService.createLivestreamMessage(from: status)
print("📺 Livestream message created: \(String(describing: self.livestream))")
} else {
print("📺 Stream is offline or status check failed")
self.livestream = nil
}
}
}
}
func loadContent(mediaType: JellyfinService.MediaType = .sermons) async {
currentMediaType = mediaType
guard !isLoading else { return }
isLoading = true
error = nil
// Cancel any existing task
currentTask?.cancel()
// Create a new task for content loading
currentTask = Task {
do {
// Check OwnCast stream status
if !Task.isCancelled {
let streamStatus = try? await owncastService.getStreamStatus()
print("📺 Initial stream status: \(String(describing: streamStatus))")
if let status = streamStatus, status.online {
print("📺 Stream is online on initial load! Creating livestream message")
self.livestream = owncastService.createLivestreamMessage(from: status)
print("📺 Initial livestream message created: \(String(describing: self.livestream))")
} else {
print("📺 Stream is offline on initial load")
self.livestream = nil
}
}
// Set media type and fetch content
if !Task.isCancelled {
jellyfinService.setType(mediaType)
let sermons = try await jellyfinService.fetchSermons(type: mediaType == .sermons ? .sermon : .liveArchive)
// Create simple date formatter
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
formatter.timeZone = TimeZone(identifier: "America/New_York")
// Convert sermons to messages
self.messages = sermons.map { sermon in
Message(
id: sermon.id,
title: sermon.title,
description: sermon.description,
speaker: sermon.speaker,
videoUrl: sermon.videoUrl ?? "",
thumbnailUrl: sermon.thumbnail ?? "",
duration: 0,
isLiveStream: sermon.type == .liveArchive,
isPublished: true,
isDeleted: false,
liveBroadcastStatus: sermon.type == .liveArchive ? "live" : "none",
date: formatter.string(from: sermon.date)
)
}
.sorted { $0.date > $1.date }
// Update available years and months
updateAvailableFilters()
// Initialize filtered messages with all messages
self.filteredMessages = self.messages
// Only show error if both content and livestream failed
if self.messages.isEmpty && self.livestream == nil {
self.error = JellyfinService.JellyfinError.noVideosFound
}
}
} catch {
if !Task.isCancelled {
self.error = error
print("Error loading content: \(error.localizedDescription)")
}
}
if !Task.isCancelled {
isLoading = false
}
}
// Wait for the task to complete
await currentTask?.value
}
func refreshContent() async {
// Check stream status first
let streamStatus = try? await owncastService.getStreamStatus()
if let status = streamStatus, status.online {
self.livestream = owncastService.createLivestreamMessage(from: status)
} else {
self.livestream = nil
}
// Then load the rest of the content
await loadContent(mediaType: currentMediaType)
}
private func updateAvailableFilters() {
// Get messages for current media type
let currentMessages = messages.filter { message in
message.isLiveStream == (currentMediaType == .livestreams)
}
// Create date formatter
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
// Get unique years first
var years = Set<String>()
var monthsByYear: [String: Set<String>] = [:]
for message in currentMessages {
if let date = formatter.date(from: message.date) {
let calendar = Calendar.current
let year = String(calendar.component(.year, from: date))
let month = String(format: "%02d", calendar.component(.month, from: date))
years.insert(year)
// Group months by year
if monthsByYear[year] == nil {
monthsByYear[year] = Set<String>()
}
monthsByYear[year]?.insert(month)
}
}
// Sort years descending (newest first)
availableYears = Array(years).sorted(by: >)
// Get months only for selected year (first year by default)
if let selectedYear = availableYears.first,
let monthsForYear = monthsByYear[selectedYear] {
availableMonths = Array(monthsForYear).sorted()
} else {
availableMonths = []
}
}
// Add a method to update months when year changes
func updateMonthsForYear(_ year: String) {
// Get messages for current media type and year
let currentMessages = messages.filter { message in
message.isLiveStream == (currentMediaType == .livestreams) &&
message.date.hasPrefix(year)
}
// Create date formatter
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
// Get months for the selected year
var months = Set<String>()
for message in currentMessages {
if let date = formatter.date(from: message.date) {
let calendar = Calendar.current
let month = String(format: "%02d", calendar.component(.month, from: date))
months.insert(month)
}
}
// Sort months ascending (Jan to Dec)
availableMonths = Array(months).sorted()
}
func filterContent(year: String? = nil, month: String? = nil) {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
// Filter by type first
let typeFiltered = messages.filter { message in
message.isLiveStream == (currentMediaType == .livestreams)
}
// Then filter by date components
let dateFiltered = typeFiltered.filter { message in
guard let date = formatter.date(from: message.date) else { return false }
let calendar = Calendar.current
if let year = year {
let messageYear = String(calendar.component(.year, from: date))
if messageYear != year { return false }
}
if let month = month {
let messageMonth = String(format: "%02d", calendar.component(.month, from: date))
if messageMonth != month { return false }
}
return true
}
// Sort by date, newest first
filteredMessages = dateFiltered.sorted { $0.date > $1.date }
}
}

View file

@ -1,26 +0,0 @@
import Foundation
@MainActor
class OwncastViewModel: ObservableObject {
@Published var streamUrl: URL?
@Published var isLoading = false
private let owncastService = OwnCastService.shared
private let baseUrl = "https://stream.rockvilletollandsda.church"
func checkStreamStatus() async {
isLoading = true
defer { isLoading = false }
do {
let status = try await owncastService.getStreamStatus()
if status.online {
streamUrl = URL(string: "\(baseUrl)/hls/stream.m3u8")
} else {
streamUrl = nil
}
} catch {
print("Failed to check stream status:", error)
streamUrl = nil
}
}
}

View file

@ -1,148 +0,0 @@
import Foundation
import AVKit
@MainActor
class SermonBrowserViewModel: ObservableObject {
@Published private(set) var sermons: [Sermon] = []
@Published private(set) var isLoading = false
@Published private(set) var error: Error?
@Published var selectedType: SermonType = .sermon
@Published var selectedYear: Int?
@Published var selectedMonth: Int?
let jellyfinService: JellyfinService
var organizedSermons: [Int: [Int: [Sermon]]] {
var filteredSermons = sermons.filter { $0.type == selectedType }
// Apply year filter if selected
if let selectedYear = selectedYear {
filteredSermons = filteredSermons.filter {
Calendar.current.component(.year, from: $0.date) == selectedYear
}
}
// Apply month filter if selected
if let selectedMonth = selectedMonth {
filteredSermons = filteredSermons.filter {
Calendar.current.component(.month, from: $0.date) == selectedMonth
}
}
return Dictionary(grouping: filteredSermons) { sermon in
Calendar.current.component(.year, from: sermon.date)
}.mapValues { yearSermons in
Dictionary(grouping: yearSermons) { sermon in
Calendar.current.component(.month, from: sermon.date)
}
}
}
var years: [Int] {
let filteredSermons = sermons.filter { $0.type == selectedType }
let allYears = Set(filteredSermons.map {
Calendar.current.component(.year, from: $0.date)
})
return Array(allYears).sorted(by: >)
}
func months(for year: Int) -> [Int] {
let yearSermons = sermons.filter {
$0.type == selectedType &&
Calendar.current.component(.year, from: $0.date) == year
}
return Array(Set(yearSermons.map {
Calendar.current.component(.month, from: $0.date)
})).sorted(by: >)
}
func sermons(for year: Int, month: Int) -> [Sermon] {
organizedSermons[year]?[month]?.sorted(by: { $0.date > $1.date }) ?? []
}
@MainActor
init(jellyfinService: JellyfinService) {
self.jellyfinService = jellyfinService
}
@MainActor
convenience init() {
self.init(jellyfinService: JellyfinService.shared)
}
@MainActor
func fetchSermons() async throws {
isLoading = true
error = nil
do {
sermons = try await jellyfinService.fetchSermons(type: .sermon)
if let firstYear = years.first {
selectedYear = firstYear
if let firstMonth = months(for: firstYear).first {
selectedMonth = firstMonth
}
}
} catch {
self.error = error
throw error
}
isLoading = false
}
func selectType(_ type: SermonType) {
selectedType = type
selectedYear = nil
selectedMonth = nil
if let firstYear = years.first {
selectedYear = firstYear
if let firstMonth = months(for: firstYear).first {
selectedMonth = firstMonth
}
}
}
func selectYear(_ year: Int?) {
selectedYear = year
selectedMonth = nil
if let year = year,
let firstMonth = months(for: year).first {
selectedMonth = firstMonth
}
}
func selectMonth(_ month: Int?) {
selectedMonth = month
}
@MainActor
func loadSermons() async {
isLoading = true
error = nil
do {
sermons = try await jellyfinService.fetchSermons(type: .sermon)
if let firstYear = years.first {
selectedYear = firstYear
if let firstMonth = months(for: firstYear).first {
selectedMonth = firstMonth
}
}
} catch {
self.error = error
}
isLoading = false
}
@MainActor
func requestPermissions() async {
let permissionsManager = PermissionsManager.shared
permissionsManager.requestLocationAccess()
}
}

View file

@ -8,6 +8,7 @@ struct Belief: Identifiable {
} }
struct BeliefsView: View { struct BeliefsView: View {
@Environment(\.dismiss) private var dismiss
let beliefs = [ let beliefs = [
Belief(id: 1, title: "The Holy Scriptures", Belief(id: 1, title: "The Holy Scriptures",
summary: "The Holy Scriptures, Old and New Testaments, are the written Word of God, given by divine inspiration. The inspired authors spoke and wrote as they were moved by the Holy Spirit.", summary: "The Holy Scriptures, Old and New Testaments, are the written Word of God, given by divine inspiration. The inspired authors spoke and wrote as they were moved by the Holy Spirit.",
@ -126,54 +127,6 @@ struct BeliefsView: View {
@State private var selectedBelief: Belief? @State private var selectedBelief: Belief?
private func formatVerseForURL(_ verse: String) -> String {
// Convert "Romans 4:11" to "rom.4.11"
let bookMap = [
"Genesis": "gen", "Exodus": "exo", "Leviticus": "lev", "Numbers": "num",
"Deuteronomy": "deu", "Joshua": "jos", "Judges": "jdg", "Ruth": "rut",
"1 Samuel": "1sa", "2 Samuel": "2sa", "1 Kings": "1ki", "2 Kings": "2ki",
"1 Chronicles": "1ch", "2 Chronicles": "2ch", "Ezra": "ezr", "Nehemiah": "neh",
"Esther": "est", "Job": "job", "Psalm": "psa", "Psalms": "psa", "Proverbs": "pro",
"Ecclesiastes": "ecc", "Song of Solomon": "sng", "Isaiah": "isa", "Jeremiah": "jer",
"Lamentations": "lam", "Ezekiel": "ezk", "Daniel": "dan", "Hosea": "hos",
"Joel": "jol", "Amos": "amo", "Obadiah": "oba", "Jonah": "jon",
"Micah": "mic", "Nahum": "nam", "Habakkuk": "hab", "Zephaniah": "zep",
"Haggai": "hag", "Zechariah": "zec", "Malachi": "mal", "Matthew": "mat",
"Mark": "mrk", "Luke": "luk", "John": "jhn", "Acts": "act",
"Romans": "rom", "1 Corinthians": "1co", "2 Corinthians": "2co", "Galatians": "gal",
"Ephesians": "eph", "Philippians": "php", "Colossians": "col", "1 Thessalonians": "1th",
"2 Thessalonians": "2th", "1 Timothy": "1ti", "2 Timothy": "2ti", "Titus": "tit",
"Philemon": "phm", "Hebrews": "heb", "James": "jas", "1 Peter": "1pe",
"2 Peter": "2pe", "1 John": "1jn", "2 John": "2jn", "3 John": "3jn",
"Jude": "jud", "Revelation": "rev"
]
let components = verse.components(separatedBy: " ")
guard components.count >= 2 else { return verse.lowercased() }
// Handle book name (including numbered books like "1 Corinthians")
var bookName = ""
var remainingComponents: [String] = components
if let firstComponent = components.first, let _ = Int(firstComponent) {
if components.count >= 2 {
bookName = components[0] + " " + components[1]
remainingComponents = Array(components.dropFirst(2))
}
} else {
bookName = components[0]
remainingComponents = Array(components.dropFirst())
}
guard let bookCode = bookMap[bookName] else { return verse.lowercased() }
// Format chapter and verse
let reference = remainingComponents.joined(separator: "")
.replacingOccurrences(of: ":", with: ".")
.replacingOccurrences(of: "-", with: "-")
return "\(bookCode).\(reference)"
}
var body: some View { var body: some View {
List { List {
@ -181,7 +134,9 @@ struct BeliefsView: View {
.font(.subheadline) .font(.subheadline)
.foregroundColor(.secondary) .foregroundColor(.secondary)
.padding(.vertical, 8) .padding(.vertical, 8)
#if os(iOS)
.listRowSeparator(.hidden) .listRowSeparator(.hidden)
#endif
ForEach(beliefs) { belief in ForEach(beliefs) { belief in
Button(action: { Button(action: {
@ -206,6 +161,13 @@ struct BeliefsView: View {
} }
} }
.navigationTitle("Our Beliefs") .navigationTitle("Our Beliefs")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") {
dismiss()
}
}
}
.sheet(item: $selectedBelief) { belief in .sheet(item: $selectedBelief) { belief in
NavigationStack { NavigationStack {
ScrollView { ScrollView {
@ -221,31 +183,25 @@ struct BeliefsView: View {
.padding(.horizontal) .padding(.horizontal)
ForEach(belief.verses, id: \.self) { verse in ForEach(belief.verses, id: \.self) { verse in
Button(action: { VerseDisplayView(reference: verse)
let formattedVerse = formatVerseForURL(verse) .padding(.horizontal)
if let url = URL(string: "https://www.bible.com/bible/1/\(formattedVerse)") { .padding(.vertical, 8)
UIApplication.shared.open(url)
}
}) {
Text(verse)
.foregroundColor(.primary)
Spacer()
Image(systemName: "book.fill")
.foregroundColor(.secondary)
}
.padding(.horizontal)
.padding(.vertical, 8)
.background(Color(.systemBackground))
} }
} }
.padding(.vertical) .padding(.vertical)
#if os(iOS)
.background(Color(.secondarySystemBackground)) .background(Color(.secondarySystemBackground))
#else
.background(Color.secondary.opacity(0.1))
#endif
} }
} }
.padding(.vertical) .padding(.vertical)
} }
.navigationTitle(belief.title) .navigationTitle(belief.title)
#if os(iOS)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
#endif
.toolbar { .toolbar {
ToolbarItem(placement: .topBarTrailing) { ToolbarItem(placement: .topBarTrailing) {
Button("Done") { Button("Done") {
@ -257,3 +213,112 @@ struct BeliefsView: View {
} }
} }
} }
// MARK: - Bible Verse Integration
struct BibleVerseModel: Codable {
let text: String
let reference: String
}
struct VerseDisplayView: View {
let reference: String
@State private var verseText: String = ""
@State private var isLoading: Bool = false
@State private var showingFullVerse: Bool = false
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Button(action: {
if verseText.isEmpty {
loadVerse()
} else {
showingFullVerse.toggle()
}
}) {
HStack {
Text(reference)
.font(.subheadline)
.fontWeight(.medium)
.foregroundColor(.primary)
Spacer()
if isLoading {
ProgressView()
.scaleEffect(0.8)
} else if !verseText.isEmpty {
Image(systemName: showingFullVerse ? "chevron.up" : "chevron.down")
.font(.caption)
.foregroundColor(.secondary)
} else {
Image(systemName: "book.fill")
.foregroundColor(Color(hex: "fb8b23"))
}
}
}
.buttonStyle(.plain)
if showingFullVerse && !verseText.isEmpty {
Text(verseText)
.font(.subheadline)
.foregroundColor(.secondary)
.padding(.top, 4)
.fixedSize(horizontal: false, vertical: true)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 8))
}
private func loadVerse() {
isLoading = true
Task {
let versesJson = fetchBibleVerseJson(query: reference)
await MainActor.run {
isLoading = false
// Use Rust function for JSON parsing and text extraction - NO business logic in Swift!
let extractedText = extractFullVerseText(versesJson: versesJson)
if !extractedText.isEmpty {
verseText = extractedText
showingFullVerse = true
} else {
verseText = "Unable to load verse text"
showingFullVerse = true
}
}
}
}
}
// MARK: - Three Angels Messages
struct ThreeAngelsMessages {
static func getMessages() -> [(title: String, reference: String, description: String)] {
return [
(
title: "First Angel's Message",
reference: "Revelation 14:6-7",
description: "Fear God and give glory to Him, for the hour of His judgment has come"
),
(
title: "Second Angel's Message",
reference: "Revelation 14:8",
description: "Babylon is fallen, is fallen, that great city"
),
(
title: "Third Angel's Message",
reference: "Revelation 14:9-12",
description: "Keep the commandments of God and the faith of Jesus"
)
]
}
static func getAllReferences() -> String {
return "Revelation 14:6-7,Revelation 14:8,Revelation 14:9-12"
}
}

View file

@ -1,823 +0,0 @@
import SwiftUI
import Foundation
import PDFKit
struct PDFViewer: UIViewRepresentable {
let url: URL
@Binding var isLoading: Bool
@Binding var error: Error?
@Binding var hasInteracted: Bool
@State private var downloadTask: Task<Void, Never>?
@State private var documentTask: Task<Void, Never>?
@State private var pageCount: Int = 0
@State private var currentPage: Int = 1
@State private var pdfData: Data?
func makeUIView(context: Context) -> PDFView {
let pdfView = PDFView()
pdfView.autoScales = true
pdfView.displayMode = .twoUpContinuous
pdfView.displayDirection = .horizontal
pdfView.backgroundColor = .systemBackground
pdfView.usePageViewController(true)
pdfView.delegate = context.coordinator
return pdfView
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func updateUIView(_ uiView: PDFView, context: Context) {
// Only start a new download if we don't have the data yet
if pdfData == nil {
Task { @MainActor in
await startDownload(for: uiView)
}
} else if uiView.document == nil && documentTask == nil {
Task { @MainActor in
await createDocument(for: uiView)
}
}
}
class Coordinator: NSObject, PDFViewDelegate {
var parent: PDFViewer
init(_ parent: PDFViewer) {
self.parent = parent
}
func pdfViewPageChanged(_ notification: Notification) {
if !parent.hasInteracted {
parent.hasInteracted = true
}
}
}
private func createDocument(for pdfView: PDFView) async {
documentTask?.cancel()
documentTask = Task {
do {
guard let data = pdfData else { return }
let document = try await createPDFDocument(from: data)
if !Task.isCancelled {
await MainActor.run {
pdfView.document = document
pageCount = document.pageCount
isLoading = false
}
}
} catch {
if !Task.isCancelled {
await MainActor.run {
self.error = error
isLoading = false
}
}
}
await MainActor.run {
documentTask = nil
}
}
}
private func startDownload(for pdfView: PDFView) async {
// Cancel any existing task
downloadTask?.cancel()
// Create new task
downloadTask = Task {
await MainActor.run {
isLoading = true
error = nil
}
do {
// Download PDF data
let (data, _) = try await downloadPDFData()
// Check if task was cancelled
if Task.isCancelled { return }
// Store the data
await MainActor.run {
self.pdfData = data
}
} catch {
if !Task.isCancelled {
await MainActor.run {
self.error = error
isLoading = false
}
}
}
}
}
private func createPDFDocument(from data: Data) async throws -> PDFDocument {
return try await Task.detached {
guard let document = PDFDocument(data: data) else {
throw URLError(.cannotDecodeContentData)
}
return document
}.value
}
private func downloadPDFData() async throws -> (Data, URLResponse) {
var request = URLRequest(url: url)
request.timeoutInterval = 30
request.setValue("application/pdf", forHTTPHeaderField: "Accept")
request.setValue("application/pdf", forHTTPHeaderField: "Content-Type")
if let token = UserDefaults.standard.string(forKey: "authToken") {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
return try await URLSession.shared.data(for: request)
}
static func dismantleUIView(_ uiView: PDFView, coordinator: ()) {
uiView.document = nil
}
}
struct BulletinListView: View {
@StateObject private var viewModel = BulletinViewModel()
var body: some View {
NavigationView {
Group {
if viewModel.isLoading && viewModel.bulletins.isEmpty {
ProgressView()
} else if let error = viewModel.error {
VStack {
Text("Error loading bulletins")
.font(.headline)
.foregroundColor(.red)
Text(error.localizedDescription)
.font(.subheadline)
.foregroundColor(.secondary)
Button("Retry") {
Task {
await viewModel.loadBulletins()
}
}
.padding()
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(8)
}
.padding()
} else if viewModel.bulletins.isEmpty {
VStack(spacing: 16) {
Image(systemName: "doc.text")
.font(.system(size: 50))
.foregroundColor(.secondary)
Text("No Bulletins Available")
.font(.headline)
Text("Check back later for bulletins.")
.font(.subheadline)
.foregroundColor(.secondary)
Button("Refresh") {
Task {
await viewModel.loadBulletins()
}
}
.padding()
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(8)
}
.padding()
} else {
List {
ForEach(viewModel.bulletins) { bulletin in
NavigationLink(destination: BulletinDetailView(bulletin: bulletin)) {
VStack(alignment: .leading, spacing: 4) {
Text(bulletin.date.formatted(date: .long, time: .omitted))
.font(.headline)
Text(bulletin.title)
.font(.subheadline)
.foregroundColor(.secondary)
}
.padding(.vertical, 4)
}
}
}
.refreshable {
await viewModel.loadBulletins()
}
}
}
.navigationTitle("Church Bulletins")
.task {
if viewModel.bulletins.isEmpty {
await viewModel.loadBulletins()
}
}
}
}
}
struct BulletinDetailView: View {
let bulletin: Bulletin
@State private var isLoading = false
@State private var error: Error?
@State private var showPDFViewer = false
@State private var showScrollIndicator = true
@State private var hasInteracted = false
var body: some View {
ScrollView {
VStack(spacing: 24) {
// Header
VStack(spacing: 8) {
Text(bulletin.date.formatted(date: .long, time: .omitted))
.font(.subheadline)
.foregroundColor(.secondary)
Text(bulletin.title)
.font(.title)
.fontWeight(.bold)
.multilineTextAlignment(.center)
}
.padding(.vertical, 16)
.frame(maxWidth: .infinity)
.background(
RoundedRectangle(cornerRadius: 15)
.fill(Color(.systemBackground))
.shadow(color: .black.opacity(0.1), radius: 10, x: 0, y: 5)
)
.padding(.horizontal)
// PDF Button
if bulletin.pdf != nil {
Button(action: {
showPDFViewer = true
showScrollIndicator = true
hasInteracted = false
}) {
HStack {
Image(systemName: "doc.fill")
.font(.title3)
Text("View PDF")
.font(.headline)
}
.frame(maxWidth: .infinity)
.padding()
.background(
LinearGradient(
gradient: Gradient(colors: [Color.blue, Color.blue.opacity(0.8)]),
startPoint: .leading,
endPoint: .trailing
)
)
.foregroundColor(.white)
.cornerRadius(12)
.shadow(color: .blue.opacity(0.3), radius: 8, x: 0, y: 4)
}
.padding(.horizontal)
.padding(.bottom, 16)
}
// Content
BulletinContentView(bulletin: bulletin)
.padding()
.background(
RoundedRectangle(cornerRadius: 15)
.fill(Color(.systemBackground))
.shadow(color: .black.opacity(0.1), radius: 10, x: 0, y: 5)
)
.padding(.horizontal)
}
.padding(.vertical)
}
.background(Color(.systemGroupedBackground))
.sheet(isPresented: $showPDFViewer) {
if let url = URL(string: bulletin.pdfUrl) {
NavigationStack {
ZStack {
PDFViewer(url: url, isLoading: $isLoading, error: $error, hasInteracted: $hasInteracted)
.ignoresSafeArea()
if isLoading {
ProgressView()
.progressViewStyle(.circular)
.scaleEffect(1.5)
.tint(.white)
}
if let error = error {
VStack(spacing: 16) {
Text("Error loading PDF")
.font(.headline)
.foregroundColor(.red)
Text(error.localizedDescription)
.font(.subheadline)
.foregroundColor(.secondary)
Button("Try Again") {
self.error = nil
}
.buttonStyle(.bordered)
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(10)
}
if showScrollIndicator && !hasInteracted {
VStack {
Spacer()
HStack {
Spacer()
VStack(spacing: 8) {
Image(systemName: "arrow.left.and.right")
.font(.title)
.foregroundColor(.white)
Text("Swipe to navigate")
.font(.caption)
.foregroundColor(.white)
Button("Got it") {
withAnimation {
showScrollIndicator = false
hasInteracted = true
}
}
.font(.caption)
.foregroundColor(.white)
.padding(.top, 4)
}
.padding()
.background(Color.black.opacity(0.7))
.cornerRadius(10)
.padding()
}
}
}
}
.navigationTitle("PDF Viewer")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") {
showPDFViewer = false
}
}
}
}
}
}
}
}
// Update the main BulletinView to use BulletinListView
struct BulletinView: View {
var body: some View {
BulletinListView()
}
}
struct BulletinContentView: View {
let bulletin: Bulletin
// Tuple to represent processed content segments
typealias ContentSegment = (id: UUID, text: String, type: ContentType, reference: String?)
enum ContentType {
case text
case hymn(number: Int)
case responsiveReading(number: Int)
case bibleVerse
case sectionHeader
}
private let sectionOrder = [
("Sabbath School", \Bulletin.sabbathSchool),
("Divine Worship", \Bulletin.divineWorship),
("Scripture Reading", \Bulletin.scriptureReading),
("Sunset", \Bulletin.sunset)
]
private func cleanHTML(_ text: String) -> String {
// Remove HTML tags
var cleaned = text.replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression)
// Replace common HTML entities
let entities = [
"&amp;": "&",
"&lt;": "<",
"&gt;": ">",
"&quot;": "\"",
"&apos;": "'",
"&#39;": "'",
"&nbsp;": " ",
"&ndash;": "",
"&mdash;": "",
"&bull;": "",
"&aelig;": "æ",
"\\u003c": "<",
"\\u003e": ">",
"\\r\\n": " "
]
for (entity, replacement) in entities {
cleaned = cleaned.replacingOccurrences(of: entity, with: replacement)
}
// Clean up whitespace and normalize spaces
cleaned = cleaned.replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
cleaned = cleaned.replacingOccurrences(of: #"(\d+)\s*:\s*(\d+)"#, with: "$1:$2", options: .regularExpression)
cleaned = cleaned.trimmingCharacters(in: .whitespacesAndNewlines)
return cleaned
}
private func processLine(_ line: String) -> [ContentSegment] {
// Clean HTML first
let cleanedLine = cleanHTML(line)
var segments: [ContentSegment] = []
let nsLine = cleanedLine as NSString
// Check for section headers
if let headerSegment = processHeader(cleanedLine, nsLine) {
segments.append(headerSegment)
return segments
}
// Process hymn numbers
if let hymnSegments = processHymns(cleanedLine, nsLine) {
segments.append(contentsOf: hymnSegments)
return segments
}
// Process responsive readings
if let readingSegments = processResponsiveReadings(cleanedLine, nsLine) {
segments.append(contentsOf: readingSegments)
return segments
}
// Process Bible verses
if let verseSegments = processBibleVerses(cleanedLine, nsLine) {
segments.append(contentsOf: verseSegments)
return segments
}
// If no special processing was done, add as regular text
segments.append((id: UUID(), text: cleanedLine, type: .text, reference: nil))
return segments
}
private func processHeader(_ line: String, _ nsLine: NSString) -> ContentSegment? {
let headerPatterns = [
// Sabbath School headers
#"^(Sabbath School):?"#,
#"^(Song Service):?"#,
#"^(Leadership):?"#,
#"^(Lesson Study):?"#,
#"^(Mission Story):?"#,
#"^(Welcome):?"#,
#"^(Opening Song):?"#,
#"^(Opening Prayer):?"#,
#"^(Mission Spotlight):?"#,
#"^(Bible Study):?"#,
#"^(Closing Song):?"#,
#"^(Closing Prayer):?"#,
// Divine Worship headers
#"^(Announcements):?"#,
#"^(Call To Worship):?"#,
#"^(Opening Hymn):?"#,
#"^(Prayer & Praises):?"#,
#"^(Prayer Song):?"#,
#"^(Offering):?"#,
#"^(Children's Story):?"#,
#"^(Special Music):?"#,
#"^(Scripture Reading):?"#,
#"^(Sermon):?"#,
#"^(Closing Hymn):?"#,
#"^(Benediction):?"#
]
for pattern in headerPatterns {
let headerRegex = try! NSRegularExpression(pattern: pattern, options: [.caseInsensitive])
let headerMatches = headerRegex.matches(in: line, range: NSRange(location: 0, length: nsLine.length))
if !headerMatches.isEmpty {
let headerText = nsLine.substring(with: headerMatches[0].range(at: 1))
.trimmingCharacters(in: .whitespaces)
return (id: UUID(), text: headerText, type: .sectionHeader, reference: nil)
}
}
return nil
}
private func processHymns(_ line: String, _ nsLine: NSString) -> [ContentSegment]? {
let hymnPattern = #"(?:Hymn(?:al)?\s+(?:#\s*)?|#\s*)(\d+)(?:\s+["""]([^"""]*)[""])?.*"#
let hymnRegex = try! NSRegularExpression(pattern: hymnPattern, options: [.caseInsensitive])
let hymnMatches = hymnRegex.matches(in: line, range: NSRange(location: 0, length: nsLine.length))
if hymnMatches.isEmpty { return nil }
var segments: [ContentSegment] = []
var lastIndex = 0
for match in hymnMatches {
if match.range.location > lastIndex {
let text = nsLine.substring(with: NSRange(location: lastIndex, length: match.range.location - lastIndex))
if !text.isEmpty {
segments.append((id: UUID(), text: text.trimmingCharacters(in: .whitespaces), type: .text, reference: nil))
}
}
let hymnNumber = Int(nsLine.substring(with: match.range(at: 1)))!
let fullHymnText = nsLine.substring(with: match.range)
segments.append((id: UUID(), text: fullHymnText, type: .hymn(number: hymnNumber), reference: nil))
lastIndex = match.range.location + match.range.length
}
if lastIndex < nsLine.length {
let text = nsLine.substring(from: lastIndex)
if !text.isEmpty {
segments.append((id: UUID(), text: text.trimmingCharacters(in: .whitespaces), type: .text, reference: nil))
}
}
return segments
}
private func processResponsiveReadings(_ line: String, _ nsLine: NSString) -> [ContentSegment]? {
let responsivePattern = #"(?:Responsive\s+Reading\s+(?:#\s*)?|#\s*)(\d+)(?:\s+["""]([^"""]*)[""])?.*"#
let responsiveRegex = try! NSRegularExpression(pattern: responsivePattern, options: [.caseInsensitive])
let responsiveMatches = responsiveRegex.matches(in: line, range: NSRange(location: 0, length: nsLine.length))
if responsiveMatches.isEmpty { return nil }
var segments: [ContentSegment] = []
var lastIndex = 0
for match in responsiveMatches {
if match.range.location > lastIndex {
let text = nsLine.substring(with: NSRange(location: lastIndex, length: match.range.location - lastIndex))
if !text.isEmpty {
segments.append((id: UUID(), text: text.trimmingCharacters(in: .whitespaces), type: .text, reference: nil))
}
}
let readingNumber = Int(nsLine.substring(with: match.range(at: 1)))!
let fullReadingText = nsLine.substring(with: match.range)
segments.append((id: UUID(), text: fullReadingText, type: .responsiveReading(number: readingNumber), reference: nil))
lastIndex = match.range.location + match.range.length
}
if lastIndex < nsLine.length {
let text = nsLine.substring(from: lastIndex)
if !text.isEmpty {
segments.append((id: UUID(), text: text.trimmingCharacters(in: .whitespaces), type: .text, reference: nil))
}
}
return segments
}
private func processBibleVerses(_ line: String, _ nsLine: NSString) -> [ContentSegment]? {
let versePattern = #"(?:^|\s|[,;])\s*(?:(?:1|2|3|I|II|III|First|Second|Third)\s+)?(?:Genesis|Exodus|Leviticus|Numbers|Deuteronomy|Joshua|Judges|Ruth|(?:1st|2nd|1|2)\s*Samuel|(?:1st|2nd|1|2)\s*Kings|(?:1st|2nd|1|2)\s*Chronicles|Ezra|Nehemiah|Esther|Job|Psalms?|Proverbs|Ecclesiastes|Song\s+of\s+Solomon|Isaiah|Jeremiah|Lamentations|Ezekiel|Daniel|Hosea|Joel|Amos|Obadiah|Jonah|Micah|Nahum|Habakkuk|Zephaniah|Haggai|Zechariah|Malachi|Matthew|Mark|Luke|John|Acts|Romans|(?:1st|2nd|1|2)\s*Corinthians|Galatians|Ephesians|Philippians|Colossians|(?:1st|2nd|1|2)\s*Thessalonians|(?:1st|2nd|1|2)\s*Timothy|Titus|Philemon|Hebrews|James|(?:1st|2nd|1|2)\s*Peter|(?:1st|2nd|3rd|1|2|3)\s*John|Jude|Revelation)s?\s+\d+(?:[:.]\d+(?:-\d+)?)?(?:\s*,\s*\d+(?:[:.]\d+(?:-\d+)?)?)*"#
let verseRegex = try! NSRegularExpression(pattern: versePattern, options: [.caseInsensitive])
let verseMatches = verseRegex.matches(in: line, range: NSRange(location: 0, length: nsLine.length))
if verseMatches.isEmpty { return nil }
var segments: [ContentSegment] = []
var lastIndex = 0
for match in verseMatches {
if match.range.location > lastIndex {
let text = nsLine.substring(with: NSRange(location: lastIndex, length: match.range.location - lastIndex))
if !text.isEmpty {
segments.append((id: UUID(), text: text.trimmingCharacters(in: .whitespaces), type: .text, reference: nil))
}
}
let verseText = nsLine.substring(with: match.range)
.trimmingCharacters(in: .whitespaces)
// Extract the complete verse reference including chapter and verse
let referencePattern = #"(?:(?:1|2|3|I|II|III|First|Second|Third)\s+)?(?:Genesis|Exodus|Leviticus|Numbers|Deuteronomy|Joshua|Judges|Ruth|(?:1st|2nd|1|2)\s*Samuel|(?:1st|2nd|1|2)\s*Kings|(?:1st|2nd|1|2)\s*Chronicles|Ezra|Nehemiah|Esther|Job|Psalms?|Proverbs|Ecclesiastes|Song\s+of\s+Solomon|Isaiah|Jeremiah|Lamentations|Ezekiel|Daniel|Hosea|Joel|Amos|Obadiah|Jonah|Micah|Nahum|Habakkuk|Zephaniah|Haggai|Zechariah|Malachi|Matthew|Mark|Luke|John|Acts|Romans|(?:1st|2nd|1|2)\s*Corinthians|Galatians|Ephesians|Philippians|Colossians|(?:1st|2nd|1|2)\s*Thessalonians|(?:1st|2nd|1|2)\s*Timothy|Titus|Philemon|Hebrews|James|(?:1st|2nd|1|2)\s*Peter|(?:1st|2nd|3rd|1|2|3)\s*John|Jude|Revelation)s?\s+\d+(?:[:.]\d+(?:-\d+)?)?(?:\s*,\s*\d+(?:[:.]\d+(?:-\d+)?)?)*"#
let referenceRegex = try! NSRegularExpression(pattern: referencePattern, options: [.caseInsensitive])
if let referenceMatch = referenceRegex.firstMatch(in: verseText, range: NSRange(location: 0, length: verseText.count)) {
let reference = (verseText as NSString).substring(with: referenceMatch.range)
segments.append((id: UUID(), text: verseText, type: .bibleVerse, reference: reference))
}
lastIndex = match.range.location + match.range.length
}
if lastIndex < nsLine.length {
let text = nsLine.substring(from: lastIndex)
if !text.isEmpty {
segments.append((id: UUID(), text: text.trimmingCharacters(in: .whitespaces), type: .text, reference: nil))
}
}
return segments
}
private func formatBibleVerse(_ verse: String) -> String {
// Strip out translation references (e.g., "KJV")
let cleanVerse = verse.replacingOccurrences(of: #"(?:\s+(?:KJV|NIV|ESV|NKJV|NLT|RSV|ASV|CEV|GNT|MSG|NET|NRSV|WEB|YLT|DBY|WNT|BBE|DARBY|WBS|KJ21|AKJV|ASV1901|CEB|CJB|CSB|ERV|EHV|EXB|GNV|GW|ICB|ISV|JUB|LEB|MEV|MOUNCE|NOG|OJB|RGT|TLV|VOICE|WYC|WYNN|YLT1898))"#, with: "", options: [.regularExpression, .caseInsensitive])
.trimmingCharacters(in: .whitespaces)
// Convert "Romans 4:11" to "rom.4.11" for Bible.com links
let bookMap = [
"Genesis": "gen", "Exodus": "exo", "Leviticus": "lev", "Numbers": "num",
"Deuteronomy": "deu", "Joshua": "jos", "Judges": "jdg", "Ruth": "rut",
"1 Samuel": "1sa", "2 Samuel": "2sa", "1 Kings": "1ki", "2 Kings": "2ki",
"1 Chronicles": "1ch", "2 Chronicles": "2ch", "Ezra": "ezr", "Nehemiah": "neh",
"Esther": "est", "Job": "job", "Psalm": "psa", "Psalms": "psa", "Proverbs": "pro",
"Ecclesiastes": "ecc", "Song of Solomon": "sng", "Isaiah": "isa", "Jeremiah": "jer",
"Lamentations": "lam", "Ezekiel": "ezk", "Daniel": "dan", "Hosea": "hos",
"Joel": "jol", "Amos": "amo", "Obadiah": "oba", "Jonah": "jon",
"Micah": "mic", "Nahum": "nam", "Habakkuk": "hab", "Zephaniah": "zep",
"Haggai": "hag", "Zechariah": "zec", "Malachi": "mal", "Matthew": "mat",
"Mark": "mrk", "Luke": "luk", "John": "jhn", "Acts": "act",
"Romans": "rom", "1 Corinthians": "1co", "2 Corinthians": "2co", "Galatians": "gal",
"Ephesians": "eph", "Philippians": "php", "Colossians": "col", "1 Thessalonians": "1th",
"2 Thessalonians": "2th", "1 Timothy": "1ti", "2 Timothy": "2ti", "Titus": "tit",
"Philemon": "phm", "Hebrews": "heb", "James": "jas", "1 Peter": "1pe",
"2 Peter": "2pe", "1 John": "1jn", "2 John": "2jn", "3 John": "3jn",
"Jude": "jud", "Revelation": "rev"
]
let components = cleanVerse.components(separatedBy: " ")
guard components.count >= 2 else { return cleanVerse.lowercased() }
// Handle book name (including numbered books like "1 Corinthians")
var bookName = ""
var remainingComponents: [String] = components
if let firstComponent = components.first, let _ = Int(firstComponent) {
if components.count >= 2 {
bookName = components[0] + " " + components[1]
remainingComponents = Array(components.dropFirst(2))
}
} else {
bookName = components[0]
remainingComponents = Array(components.dropFirst())
}
guard let bookCode = bookMap[bookName] else { return cleanVerse.lowercased() }
// Format chapter and verse
let reference = remainingComponents.joined(separator: "")
.replacingOccurrences(of: ":", with: ".")
.replacingOccurrences(of: "-", with: "-")
.replacingOccurrences(of: ",", with: ".")
.replacingOccurrences(of: " ", with: "")
return "\(bookCode).\(reference)"
}
private func renderTextSegment(_ segment: ContentSegment) -> some View {
Text(segment.text)
.fixedSize(horizontal: false, vertical: true)
.multilineTextAlignment(.center)
.foregroundColor(.primary)
}
private func renderHymnSegment(_ segment: ContentSegment, number: Int) -> some View {
Button(action: {
AppAvailabilityService.shared.openHymnByNumber(number)
}) {
HStack(spacing: 6) {
Image(systemName: "music.note")
.foregroundColor(.blue)
Text(segment.text)
.foregroundColor(.blue)
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(Color.blue.opacity(0.1))
)
}
}
private func renderResponsiveReadingSegment(_ segment: ContentSegment, number: Int) -> some View {
Button(action: {
AppAvailabilityService.shared.openResponsiveReadingByNumber(number)
}) {
HStack {
Image(systemName: "book")
.foregroundColor(.blue)
Text("Responsive Reading #\(number)")
.foregroundColor(.blue)
}
}
}
private func renderBibleVerseSegment(_ segment: ContentSegment, reference: String) -> some View {
Button(action: {
let formattedVerse = formatBibleVerse(reference)
if let url = URL(string: "https://www.bible.com/bible/1/\(formattedVerse)") {
UIApplication.shared.open(url)
}
}) {
HStack(spacing: 6) {
Image(systemName: "book.fill")
.foregroundColor(.blue)
Text(segment.text)
.foregroundColor(.blue)
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(Color.blue.opacity(0.1))
)
}
}
private func renderSectionHeaderSegment(_ segment: ContentSegment) -> some View {
Text(segment.text)
.font(.headline)
.fontWeight(.semibold)
.foregroundColor(.primary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 16)
.padding(.bottom, 4)
.padding(.horizontal, 8)
}
private func renderLine(_ line: String) -> some View {
let trimmedLine = line.trimmingCharacters(in: .whitespaces)
guard !trimmedLine.isEmpty else { return AnyView(EmptyView()) }
return AnyView(
HStack(alignment: .center, spacing: 4) {
ForEach(processLine(trimmedLine), id: \.id) { segment in
switch segment.type {
case .text:
renderTextSegment(segment)
case .hymn(let number):
renderHymnSegment(segment, number: number)
case .responsiveReading(let number):
renderResponsiveReadingSegment(segment, number: number)
case .bibleVerse:
if let reference = segment.reference {
renderBibleVerseSegment(segment, reference: reference)
}
case .sectionHeader:
renderSectionHeaderSegment(segment)
}
}
}
.frame(maxWidth: .infinity, alignment: .center)
)
}
private func renderSection(_ title: String, content: String) -> some View {
VStack(alignment: .center, spacing: 16) {
Text(title)
.font(.title)
.fontWeight(.bold)
.foregroundColor(.primary)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.bottom, 8)
VStack(alignment: .center, spacing: 12) {
ForEach(Array(zip(content.components(separatedBy: .newlines).indices,
content.components(separatedBy: .newlines))), id: \.0) { _, line in
renderLine(line)
}
}
}
.padding(.vertical, 12)
}
var body: some View {
VStack(alignment: .center, spacing: 24) {
ForEach(sectionOrder, id: \.0) { (title, keyPath) in
let content = bulletin[keyPath: keyPath]
if !content.isEmpty {
renderSection(title, content: content)
if title != sectionOrder.last?.0 {
Divider()
.padding(.vertical, 8)
}
}
}
}
.frame(maxWidth: .infinity)
}
}
#Preview {
BulletinView()
}

View file

@ -1,151 +0,0 @@
import SwiftUI
struct BulletinListView: View {
@StateObject private var viewModel = BulletinViewModel()
var body: some View {
NavigationView {
Group {
if viewModel.isLoading {
ProgressView()
} else if let error = viewModel.error {
VStack {
Text("Error loading bulletins")
.font(.headline)
.foregroundColor(.red)
Text(error.localizedDescription)
.font(.subheadline)
.foregroundColor(.secondary)
Button("Retry") {
Task {
await viewModel.loadBulletins()
}
}
.padding()
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(8)
}
.padding()
} else if viewModel.bulletins.isEmpty {
VStack {
Text("No Bulletins")
.font(.headline)
Text("No bulletins are available at this time.")
.font(.subheadline)
.foregroundColor(.secondary)
}
.padding()
} else {
List(viewModel.bulletins) { bulletin in
NavigationLink(destination: BulletinDetailView(bulletin: bulletin)) {
BulletinRowView(bulletin: bulletin)
}
}
}
}
.navigationTitle("Church Bulletins")
.task {
await viewModel.loadBulletins()
}
}
}
}
struct BulletinSectionView: View {
let section: BulletinSection
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text(section.title)
.font(.headline)
if section.title == "Scripture Reading" {
ScriptureReadingView(content: section.content)
} else {
BulletinContentText(content: section.content)
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(10)
.shadow(radius: 2)
}
}
struct ScriptureReadingView: View {
let content: String
var body: some View {
VStack(spacing: 8) {
ForEach(content.components(separatedBy: .newlines), id: \.self) { line in
if !line.isEmpty {
Text(line)
.font(.body)
.foregroundColor(line.contains("Acts") ? .primary : .secondary)
}
}
}
}
}
struct BulletinContentText: View {
let content: String
var formattedContent: [(label: String?, value: String)] {
content.components(separatedBy: .newlines)
.map { line -> (String?, String) in
let parts = line.split(separator: ":", maxSplits: 1).map(String.init)
if parts.count == 2 {
return (parts[0].trimmingCharacters(in: .whitespaces),
parts[1].trimmingCharacters(in: .whitespaces))
}
return (nil, line)
}
.filter { !$0.1.isEmpty }
}
var body: some View {
VStack(spacing: 12) {
ForEach(formattedContent, id: \.1) { item in
if let label = item.label {
VStack(spacing: 4) {
Text(label)
.font(.headline)
.foregroundColor(.primary)
Text(item.value)
.font(.body)
.foregroundColor(.secondary)
}
} else {
Text(item.value)
.font(.body)
.foregroundColor(.secondary)
}
}
}
}
}
struct BulletinRowView: View {
let bulletin: Bulletin
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(bulletin.date.formatted(date: .long, time: .omitted))
.font(.subheadline)
.foregroundColor(.secondary)
Text(bulletin.title)
.font(.headline)
.lineLimit(2)
if let pdf = bulletin.pdf, !pdf.isEmpty {
Label("PDF Available", systemImage: "doc.fill")
.font(.caption)
.foregroundColor(.blue)
}
}
.padding(.vertical, 8)
}
}

206
Views/BulletinsView.swift Normal file
View file

@ -0,0 +1,206 @@
import SwiftUI
struct BulletinsView: View {
@Environment(ChurchDataService.self) private var dataService
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
var body: some View {
List {
if dataService.isLoading {
HStack {
Spacer()
VStack {
ProgressView("Loading bulletins...")
Text("Fetching church bulletins")
.font(.caption)
.foregroundColor(.secondary)
.padding(.top, 8)
}
Spacer()
}
.listRowSeparator(.hidden)
} else if dataService.bulletins.isEmpty {
HStack {
Spacer()
VStack(spacing: 16) {
Image(systemName: "newspaper.badge.exclamationmark")
.font(.system(size: 50))
.foregroundColor(.secondary)
Text("No Bulletins Available")
.font(.headline)
.foregroundColor(.secondary)
Text("Check back later for weekly church bulletins and announcements.")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
}
.padding()
Spacer()
}
.listRowSeparator(.hidden)
} else {
ForEach(dataService.bulletins) { bulletin in
NavigationLink {
BulletinDetailView(bulletin: bulletin)
} label: {
BulletinRowView(bulletin: bulletin)
}
}
}
}
.refreshable {
await dataService.loadAllBulletins()
}
.navigationTitle("Bulletins")
.navigationBarTitleDisplayMode(.large)
.task {
await dataService.loadAllBulletins()
}
}
}
struct BulletinRowView: View {
let bulletin: ChurchBulletin
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
var body: some View {
if horizontalSizeClass == .regular {
// iPad: Enhanced layout with more information
HStack(spacing: 20) {
// Bulletin icon
Image(systemName: "newspaper.fill")
.font(.title)
.foregroundColor(Color(hex: "fb8b23"))
.frame(width: 50, height: 50)
.background(Color(hex: "fb8b23").opacity(0.1), in: RoundedRectangle(cornerRadius: 12))
VStack(alignment: .leading, spacing: 8) {
HStack {
Spacer()
Text(bulletin.formattedDate)
.font(.subheadline)
.foregroundColor(.secondary)
.fontWeight(.medium)
if bulletin.pdfPath != nil {
Image(systemName: "arrow.down.circle.fill")
.font(.title3)
.foregroundColor(.green)
}
}
Text(bulletin.title)
.font(.system(size: 20, weight: .semibold))
.lineLimit(2)
.multilineTextAlignment(.leading)
// Service details in grid layout for iPad
HStack(spacing: 40) {
VStack(alignment: .leading, spacing: 4) {
Text("Sabbath School")
.font(.caption2)
.fontWeight(.medium)
.foregroundColor(.secondary)
.textCase(.uppercase)
Text(bulletin.sabbathSchool)
.font(.subheadline)
.lineLimit(2)
}
.frame(maxWidth: .infinity, alignment: .leading)
VStack(alignment: .leading, spacing: 4) {
Text("Divine Worship")
.font(.caption2)
.fontWeight(.medium)
.foregroundColor(.secondary)
.textCase(.uppercase)
Text(bulletin.divineWorship)
.font(.subheadline)
.lineLimit(2)
}
.frame(maxWidth: .infinity, alignment: .leading)
VStack(alignment: .leading, spacing: 4) {
Text("Scripture Reading")
.font(.caption2)
.fontWeight(.medium)
.foregroundColor(.secondary)
.textCase(.uppercase)
Text(bulletin.scriptureReading)
.font(.subheadline)
.lineLimit(2)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
Image(systemName: "chevron.right")
.font(.caption)
.foregroundColor(.secondary)
}
.padding(.vertical, 16)
} else {
// iPhone: Compact layout (current design)
HStack(spacing: 12) {
// Bulletin icon
Image(systemName: "newspaper.fill")
.font(.title2)
.foregroundColor(Color(hex: "fb8b23"))
.frame(width: 40, height: 40)
.background(Color(hex: "fb8b23").opacity(0.1), in: Circle())
VStack(alignment: .leading, spacing: 6) {
HStack {
Spacer()
if bulletin.pdfPath != nil {
Image(systemName: "arrow.down.circle.fill")
.font(.title3)
.foregroundColor(.green)
}
}
Text(bulletin.title)
.font(.system(size: 16, weight: .semibold))
.lineLimit(2)
.multilineTextAlignment(.leading)
Text(bulletin.formattedDate)
.font(.system(size: 13))
.foregroundColor(.secondary)
// Service preview
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("Sabbath School:")
.font(.caption2)
.foregroundColor(.secondary)
Text(bulletin.sabbathSchool)
.font(.caption)
.lineLimit(1)
}
Spacer()
VStack(alignment: .leading, spacing: 2) {
Text("Divine Worship:")
.font(.caption2)
.foregroundColor(.secondary)
Text(bulletin.divineWorship)
.font(.caption)
.lineLimit(1)
}
}
.padding(.top, 4)
}
Spacer()
}
.padding(.vertical, 8)
}
}
}

View file

@ -1,4 +1,14 @@
import SwiftUI import SwiftUI
import Foundation
// Response structure for cached image data
struct CachedImageResponse: Codable {
let success: Bool
let data: String?
let contentType: String?
let cached: Bool?
let error: String?
}
struct CachedAsyncImage<Content: View, Placeholder: View>: View { struct CachedAsyncImage<Content: View, Placeholder: View>: View {
private let url: URL? private let url: URL?
@ -6,6 +16,10 @@ struct CachedAsyncImage<Content: View, Placeholder: View>: View {
private let content: (Image) -> Content private let content: (Image) -> Content
private let placeholder: () -> Placeholder private let placeholder: () -> Placeholder
@State private var image: UIImage?
@State private var isLoading = false
@State private var loadError: Error?
init( init(
url: URL?, url: URL?,
scale: CGFloat = 1.0, scale: CGFloat = 1.0,
@ -20,45 +34,89 @@ struct CachedAsyncImage<Content: View, Placeholder: View>: View {
var body: some View { var body: some View {
Group { Group {
if let url = url { if let image = image {
AsyncImage( content(Image(uiImage: image))
url: url, } else if isLoading {
scale: scale, placeholder()
transaction: Transaction(animation: .easeInOut) } else if loadError != nil {
) { phase in placeholder()
switch phase {
case .empty:
placeholder()
case .success(let image):
content(image)
.task {
await storeImageInCache(image: image, url: url)
}
case .failure(_):
placeholder()
@unknown default:
placeholder()
}
}
.task {
await loadImageFromCache(url: url)
}
} else { } else {
placeholder() placeholder()
} }
} }
} .onAppear {
loadCachedImage()
private func loadImageFromCache(url: URL) async { }
guard let cachedImage = await ImageCache.shared.image(for: url) else { return } .onChange(of: url) { _, newURL in
_ = content(Image(uiImage: cachedImage)) image = nil
} loadError = nil
loadCachedImage()
private func storeImageInCache(image: Image, url: URL) async {
// Convert SwiftUI Image to UIImage and cache it
let renderer = ImageRenderer(content: content(image))
if let uiImage = renderer.uiImage {
await ImageCache.shared.setImage(uiImage, for: url)
} }
} }
private func loadCachedImage() {
guard let url = url else { return }
isLoading = true
loadError = nil
// Use background queue for image processing
DispatchQueue.global(qos: .userInitiated).async {
do {
// Call Rust crate function for cached image
let jsonResponse = fetchCachedImageBase64(url: url.absoluteString)
// Parse JSON response
guard let jsonData = jsonResponse.data(using: .utf8) else {
DispatchQueue.main.async {
self.isLoading = false
self.loadError = NSError(domain: "CachedImageError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Invalid JSON response"])
}
return
}
let response = try JSONDecoder().decode(CachedImageResponse.self, from: jsonData)
if response.success, let base64Data = response.data {
// Decode base64 to image
if let imageData = Data(base64Encoded: base64Data),
let uiImage = UIImage(data: imageData) {
DispatchQueue.main.async {
withAnimation(.easeInOut(duration: 0.3)) {
self.image = uiImage
}
self.isLoading = false
}
} else {
DispatchQueue.main.async {
self.isLoading = false
self.loadError = NSError(domain: "CachedImageError", code: 2, userInfo: [NSLocalizedDescriptionKey: "Failed to decode image data"])
}
}
} else {
DispatchQueue.main.async {
self.isLoading = false
self.loadError = NSError(domain: "CachedImageError", code: 3, userInfo: [NSLocalizedDescriptionKey: response.error ?? "Unknown error"])
}
}
} catch {
DispatchQueue.main.async {
self.isLoading = false
self.loadError = error
}
}
}
}
}
// Convenience initializer for common use case
extension CachedAsyncImage where Content == Image, Placeholder == ProgressView<EmptyView, EmptyView> {
init(url: URL?, scale: CGFloat = 1.0) {
self.init(
url: url,
scale: scale,
content: { $0 },
placeholder: { ProgressView() }
)
}
} }

View file

@ -0,0 +1,98 @@
import SwiftUI
struct ContactActionRow: View {
let icon: String
let title: String
let subtitle: String
let iconColor: Color
let action: (() -> Void)?
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
init(icon: String, title: String, subtitle: String, iconColor: Color, action: (() -> Void)? = nil) {
self.icon = icon
self.title = title
self.subtitle = subtitle
self.iconColor = iconColor
self.action = action
}
var body: some View {
Group {
if let action = action {
Button(action: action) {
content
}
.buttonStyle(.plain)
} else {
content
}
}
}
private var content: some View {
HStack(spacing: 16) {
Image(systemName: icon)
.font(.title2)
.foregroundColor(iconColor)
.frame(width: 24)
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.system(size: horizontalSizeClass == .regular ? 16 : 14, weight: .semibold))
.foregroundColor(.primary)
Text(subtitle)
.font(.system(size: horizontalSizeClass == .regular ? 15 : 13, weight: .regular))
.foregroundColor(.secondary)
.multilineTextAlignment(.leading)
}
Spacer()
if action != nil {
Image(systemName: "arrow.up.right.square")
.font(.caption)
.foregroundColor(.secondary)
} else {
Image(systemName: "chevron.right")
.font(.caption)
.foregroundColor(.secondary)
}
}
.padding(.vertical, 12)
.contentShape(Rectangle())
}
}
// MARK: - Contact Actions Helper
struct ContactActions {
static func callAction(phoneNumber: String) -> (() -> Void)? {
guard UIDevice.current.userInterfaceIdiom == .phone else { return nil }
return {
let digitsOnly = phoneNumber.filter { $0.isNumber }
let phoneURL = URL(string: "tel://\(digitsOnly)")
if let url = phoneURL, UIApplication.shared.canOpenURL(url) {
UIApplication.shared.open(url)
}
}
}
static func emailAction(email: String) -> () -> Void {
return {
if let url = URL(string: "mailto:\(email)") {
UIApplication.shared.open(url)
}
}
}
static func directionsAction(address: String) -> () -> Void {
return {
let encodedAddress = address.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? address
if let url = URL(string: "https://maps.apple.com/?address=\(encodedAddress)") {
UIApplication.shared.open(url)
}
}
}
}

View file

@ -0,0 +1,476 @@
import SwiftUI
#if os(iOS)
struct ShareSheet: UIViewControllerRepresentable {
let activityItems: [Any]
func makeUIViewController(context: Context) -> UIActivityViewController {
UIActivityViewController(activityItems: activityItems, applicationActivities: nil)
}
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
}
#endif
// MARK: - Sermon Card (for Watch view)
enum SermonCardStyle {
case watch // For Watch tab (compact)
case feed // For Home feed (prominent with large thumbnail)
}
struct SermonCard: View {
let sermon: Sermon
let style: SermonCardStyle
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@State private var showingScriptureSheet = false
@State private var showingShareSheet = false
@State private var scriptureText = ""
init(sermon: Sermon, style: SermonCardStyle = .watch) {
self.sermon = sermon
self.style = style
}
var body: some View {
Button {
if let videoUrl = sermon.videoUrl {
let optimalUrl = getOptimalStreamingUrl(mediaId: sermon.id)
let urlToUse = !optimalUrl.isEmpty ? optimalUrl : videoUrl
if let url = URL(string: urlToUse) {
SharedVideoManager.shared.playVideo(url: url, title: sermon.title, artworkURL: sermon.thumbnail)
}
}
} label: {
switch style {
case .watch:
watchStyleCard
case .feed:
feedStyleCard
}
}
.buttonStyle(.plain)
.contextMenu {
Button("View Scripture", systemImage: "book.fill") {
Task {
let verses = fetchScriptureVersesForSermonJson(sermonId: sermon.id)
await MainActor.run {
scriptureText = verses
showingScriptureSheet = true
}
}
}
Button("Share Sermon", systemImage: "square.and.arrow.up") {
showingShareSheet = true
}
if sermon.audioUrl != nil && sermon.videoUrl == nil {
Button("Play Audio Only", systemImage: "speaker.wave.2") {
// TODO: Implement audio-only playback
}
}
}
.sheet(isPresented: $showingScriptureSheet) {
ScriptureSheet(scriptureText: $scriptureText)
}
#if os(iOS)
.sheet(isPresented: $showingShareSheet) {
ShareSheet(activityItems: createShareItems(for: sermon))
}
#endif
.shadow(color: .black.opacity(0.1), radius: style == .feed ? 8 : 6, x: 0, y: style == .feed ? 4 : 2)
}
// MARK: - Watch Style (Compact)
private var watchStyleCard: some View {
Group {
if horizontalSizeClass == .regular {
// iPad: Horizontal layout
HStack(spacing: 16) {
thumbnailView()
.frame(width: 120, height: 90)
watchContentView
Spacer()
playButton
}
.padding(16)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
} else {
// FIXED: Proper layout with real components
VStack(alignment: .leading, spacing: 16) {
thumbnailView(durationAlignment: .bottomTrailing)
.frame(height: 160)
watchContentView
.padding(.horizontal, 16)
.padding(.bottom, 16)
}
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
}
}
}
// MARK: - Feed Style (Prominent)
private var feedStyleCard: some View {
VStack(alignment: .leading, spacing: 0) {
thumbnailView(durationAlignment: .bottomTrailing)
.frame(height: horizontalSizeClass == .regular ? 200 : 180)
// Content section
feedContentView
.padding(16)
}
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16))
}
private func thumbnailView(durationAlignment: Alignment = .bottomTrailing) -> some View {
// FIXED: Use .fill but force proper clipping with frame
Rectangle()
.fill(.clear)
.overlay {
CachedAsyncImage(url: URL(string: sermon.thumbnail ?? sermon.image ?? "")) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
} placeholder: {
Rectangle()
.fill(.gray)
.overlay {
Text("Loading...")
.foregroundColor(.white)
}
}
}
.clipped() // Force clipping BEFORE clipShape
.clipShape(RoundedRectangle(cornerRadius: 8))
.overlay(alignment: durationAlignment) {
if let duration = sermon.durationFormatted {
Text(duration)
.font(.caption2)
.fontWeight(.medium)
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(.black.opacity(0.7), in: Capsule())
.foregroundStyle(.white)
.padding(4)
}
}
}
// MARK: - Content Views
private var watchContentView: some View {
VStack(alignment: .leading, spacing: 8) {
Text(sermon.title.trimmingCharacters(in: .whitespacesAndNewlines))
.font(.headline)
.fontWeight(.semibold)
.lineLimit(2)
.multilineTextAlignment(.leading)
if !sermon.speaker.isEmpty {
Text("by \(sermon.speaker.trimmingCharacters(in: .whitespacesAndNewlines))")
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(1)
}
if !sermon.formattedDate.isEmpty && sermon.formattedDate != "Date unknown" {
Text(sermon.formattedDate)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
private var feedContentView: some View {
VStack(alignment: .leading, spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
Text(sermon.title.trimmingCharacters(in: .whitespacesAndNewlines))
.font(.headline)
.fontWeight(.semibold)
.lineLimit(2)
.multilineTextAlignment(.leading)
.fixedSize(horizontal: false, vertical: true)
if !sermon.speaker.isEmpty {
Text("by \(sermon.speaker.trimmingCharacters(in: .whitespacesAndNewlines))")
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
if !sermon.formattedDate.isEmpty && sermon.formattedDate != "Date unknown" {
Text(sermon.formattedDate)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
private var playButton: some View {
Button {
// Play sermon
} label: {
Image(systemName: "play.circle.fill")
.font(.system(size: 24))
.foregroundStyle(Color(hex: "fb8b23"))
}
}
}
// MARK: - Event Card (for Connect view)
struct EventCard: View {
let event: ChurchEvent
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
var body: some View {
NavigationLink {
EventDetailViewWrapper(event: event)
} label: {
VStack(alignment: .leading, spacing: 0) {
// Event image or gradient
Group {
if let imageUrl = event.thumbnail ?? event.image {
CachedAsyncImage(url: URL(string: imageUrl)) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
} placeholder: {
eventGradient
}
} else {
eventGradient
}
}
.frame(height: horizontalSizeClass == .regular ? 160 : 140)
.clipped()
// Content
VStack(alignment: .leading, spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
Text(event.title)
.font(.headline)
.fontWeight(.semibold)
.lineLimit(2)
.multilineTextAlignment(.leading)
Text(event.formattedDate)
.font(.subheadline)
.foregroundStyle(.secondary)
}
if !event.location.isEmpty {
Label {
Text(event.location)
.font(.caption)
.lineLimit(1)
} icon: {
Image(systemName: "location.fill")
.font(.caption)
}
.foregroundStyle(.secondary)
}
if !event.category.isEmpty {
Text(event.category)
.font(.caption)
.fontWeight(.medium)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(.blue.opacity(0.1), in: Capsule())
.foregroundStyle(.blue)
}
}
.padding(16)
}
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
.shadow(color: .black.opacity(0.1), radius: 6, x: 0, y: 2)
}
.buttonStyle(.plain)
}
private var eventGradient: some View {
LinearGradient(
colors: [.blue, .blue.opacity(0.7)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.overlay {
Image(systemName: "calendar")
.font(.system(size: 32))
.foregroundStyle(.white.opacity(0.7))
}
}
}
// MARK: - Bulletin Card (for Discover view)
struct BulletinCard: View {
let bulletin: ChurchBulletin
var body: some View {
NavigationLink {
BulletinDetailView(bulletin: bulletin)
} label: {
VStack(alignment: .leading, spacing: 0) {
// Header with PDF icon
HStack {
Image(systemName: "doc.text.fill")
.font(.system(size: 32))
.foregroundStyle(Color(hex: "fb8b23"))
VStack(alignment: .leading, spacing: 4) {
Text("Church Bulletin")
.font(.caption)
.fontWeight(.medium)
.foregroundStyle(.secondary)
Text(bulletin.formattedDate)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
if bulletin.pdfPath != nil {
Image(systemName: "arrow.down.circle.fill")
.font(.title3)
.foregroundStyle(.green)
}
}
.padding(16)
.background(.regularMaterial)
Divider()
// Content preview
VStack(alignment: .leading, spacing: 12) {
Text(bulletin.title)
.font(.headline)
.fontWeight(.semibold)
.lineLimit(2)
.multilineTextAlignment(.leading)
VStack(alignment: .leading, spacing: 4) {
if !bulletin.sabbathSchool.isEmpty {
Text("Sabbath School: \(bulletin.sabbathSchool)")
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
if !bulletin.divineWorship.isEmpty {
Text("Divine Worship: \(bulletin.divineWorship)")
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
HStack {
Label("Tap to view", systemImage: "eye.fill")
.font(.caption)
.foregroundStyle(.blue)
Spacer()
if bulletin.pdfPath != nil {
Label("PDF Available", systemImage: "doc.fill")
.font(.caption)
.foregroundStyle(.green)
}
}
}
.padding(16)
}
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
.shadow(color: .black.opacity(0.1), radius: 6, x: 0, y: 2)
}
.buttonStyle(.plain)
}
}
// MARK: - Quick Action Card
struct QuickActionCard: View {
let title: String
let icon: String
let color: Color
let action: () -> Void
var body: some View {
Button(action: action) {
VStack(spacing: 12) {
Image(systemName: icon)
.font(.system(size: 24))
.foregroundStyle(color)
Text(title)
.font(.subheadline)
.fontWeight(.medium)
.foregroundStyle(.primary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.padding(20)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
.shadow(color: .black.opacity(0.1), radius: 6, x: 0, y: 2)
}
.buttonStyle(.plain)
}
}
#Preview {
ScrollView {
VStack(spacing: 20) {
SermonCard(sermon: Sermon.sampleSermon())
EventCard(event: ChurchEvent(
id: "2",
title: "Community Potluck Dinner",
description: "Join us for fellowship",
startTime: "2025-01-15T18:00:00-05:00",
endTime: "2025-01-15T20:00:00-05:00",
formattedTime: "6:00 PM - 8:00 PM",
formattedDate: "January 15, 2025",
formattedDateTime: "January 15, 2025 at 6:00 PM",
dayOfMonth: "15",
monthAbbreviation: "JAN",
timeString: "6:00 PM - 8:00 PM",
isMultiDay: false,
detailedTimeDisplay: "6:00 PM - 8:00 PM",
location: "Fellowship Hall",
locationUrl: nil,
image: nil,
thumbnail: nil,
category: "Social",
isFeatured: false,
recurringType: nil,
createdAt: "2025-01-10T09:00:00-05:00",
updatedAt: "2025-01-10T09:00:00-05:00"
))
BulletinCard(bulletin: ChurchBulletin(
id: "3",
title: "January 11, 2025",
date: "Saturday, January 11, 2025",
sabbathSchool: "The Book of Romans",
divineWorship: "Walking in Faith",
scriptureReading: "Romans 8:28-39",
sunset: "5:47 PM",
pdfPath: "https://example.com/bulletin.pdf",
coverImage: nil,
isActive: true
))
}
.padding()
}
}

View file

@ -0,0 +1,289 @@
import SwiftUI
struct FeedItemCard: View {
let item: FeedItem
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
var body: some View {
Group {
switch item.type {
case .sermon(let sermon):
SermonCard(sermon: sermon, style: .feed)
case .event(let event):
EventFeedCard(event: event)
case .bulletin(let bulletin):
BulletinFeedCard(bulletin: bulletin)
case .verse(let verse):
VerseFeedCard(verse: verse)
}
}
.containerRelativeFrame(.horizontal) { width, _ in
horizontalSizeClass == .regular ? min(width * 0.9, 600) : width * 0.95
}
}
}
// MARK: - Event Feed Card
struct EventFeedCard: View {
let event: ChurchEvent
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
var body: some View {
NavigationLink {
EventDetailViewWrapper(event: event)
} label: {
HStack(spacing: 16) {
// Date indicator
VStack(spacing: 4) {
Text(event.dayOfMonth)
.font(.system(size: 18, weight: .bold))
.foregroundColor(Color(hex: "fb8b23"))
Text(event.monthAbbreviation)
.font(.system(size: 10, weight: .semibold))
.foregroundColor(.secondary)
.textCase(.uppercase)
}
.frame(width: 50)
.padding(.vertical, 8)
.background(Color(hex: "fb8b23").opacity(Double(0.1)), in: RoundedRectangle(cornerRadius: 8))
// Event content
VStack(alignment: .leading, spacing: 8) {
Text(event.title)
.font(.system(size: 16, weight: .semibold))
.lineLimit(2)
if !event.description.isEmpty {
Text(event.description.stripHtml())
.font(.system(size: 14))
.foregroundColor(.secondary)
.lineLimit(2)
}
HStack(spacing: 8) {
Image(systemName: "clock.fill")
.font(.caption)
.foregroundColor(Color(hex: "fb8b23"))
Text(event.timeString)
.font(.system(size: 13, weight: .medium))
.foregroundColor(.secondary)
if !event.location.isEmpty {
Image(systemName: "location.fill")
.font(.caption)
.foregroundColor(Color(hex: "fb8b23"))
Text(event.location)
.font(.system(size: 13, weight: .medium))
.foregroundColor(.secondary)
.lineLimit(1)
}
}
}
Spacer()
// Event image (if available)
if let imageUrl = event.thumbnail ?? event.image {
AsyncImage(url: URL(string: imageUrl)) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
} placeholder: {
RoundedRectangle(cornerRadius: 8)
.fill(.secondary.opacity(0.3))
}
.frame(width: 60, height: 60)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
.padding(16)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16))
.shadow(color: .black.opacity(0.1), radius: 8, x: 0, y: 4)
}
.buttonStyle(.plain)
}
private func dayFromDate(_ dateString: String) -> String {
let formatter = ISO8601DateFormatter()
if let date = formatter.date(from: dateString) {
let dayFormatter = DateFormatter()
dayFormatter.dateFormat = "d"
return dayFormatter.string(from: date)
}
return "?"
}
private func monthFromDate(_ dateString: String) -> String {
let formatter = ISO8601DateFormatter()
if let date = formatter.date(from: dateString) {
let monthFormatter = DateFormatter()
monthFormatter.dateFormat = "MMM"
return monthFormatter.string(from: date).uppercased()
}
return "???"
}
}
// MARK: - Bulletin Feed Card
struct BulletinFeedCard: View {
let bulletin: ChurchBulletin
var body: some View {
NavigationLink {
BulletinDetailView(bulletin: bulletin)
} label: {
HStack(spacing: 16) {
// PDF icon
Image(systemName: "doc.text.fill")
.font(.system(size: 32))
.foregroundStyle(Color(hex: "fb8b23"))
.frame(width: 50)
// Bulletin content
VStack(alignment: .leading, spacing: 8) {
Text(bulletin.title)
.font(.headline)
.fontWeight(.semibold)
.lineLimit(2)
.multilineTextAlignment(.leading)
Text(bulletin.formattedDate)
.font(.subheadline)
.foregroundStyle(.secondary)
Label("Church Bulletin", systemImage: "newspaper.fill")
.font(.caption)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(.green.opacity(0.1), in: Capsule())
.foregroundStyle(.green)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(16)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16))
.shadow(color: .black.opacity(0.1), radius: 8, x: 0, y: 4)
}
.buttonStyle(.plain)
}
}
// MARK: - Bible Verse Feed Card
struct VerseFeedCard: View {
let verse: BibleVerse
var body: some View {
VStack(alignment: .leading, spacing: 16) {
HStack {
Image(systemName: "book.fill")
.foregroundStyle(Color(hex: "fb8b23"))
Text("Verse of the Day")
.font(.caption)
.fontWeight(.medium)
.foregroundStyle(.secondary)
Spacer()
}
Text("\"\(verse.text)\"")
.font(.body)
.fontWeight(.medium)
.italic()
.lineLimit(4)
.multilineTextAlignment(.leading)
HStack {
Text(verse.reference)
.font(.caption)
.fontWeight(.semibold)
.foregroundStyle(Color(hex: "fb8b23"))
if let version = verse.version {
Text(version)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Button {
// Share verse
} label: {
Image(systemName: "square.and.arrow.up")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
.padding(16)
.background(
LinearGradient(
colors: [Color(hex: "fb8b23").opacity(0.1), Color(hex: "fb8b23").opacity(0.05)],
startPoint: .topLeading,
endPoint: .bottomTrailing
),
in: RoundedRectangle(cornerRadius: 16)
)
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(Color(hex: "fb8b23").opacity(0.2), lineWidth: 1)
)
}
}
// MARK: - Color Extension
extension Color {
init(hex: String) {
let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
var int: UInt64 = 0
Scanner(string: hex).scanHexInt64(&int)
let a, r, g, b: UInt64
switch hex.count {
case 3: // RGB (12-bit)
(a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17)
case 6: // RGB (24-bit)
(a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
case 8: // ARGB (32-bit)
(a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
default:
(a, r, g, b) = (1, 1, 1, 0)
}
self.init(
.sRGB,
red: Double(r) / 255,
green: Double(g) / 255,
blue: Double(b) / 255,
opacity: Double(a) / 255
)
}
}
#Preview {
VStack(spacing: 20) {
FeedItemCard(item: FeedItem(
type: .sermon(Sermon.sampleSermon()),
timestamp: Date()
))
FeedItemCard(item: FeedItem(
type: .event(ChurchEvent.sampleEvent()),
timestamp: Date()
))
}
.padding()
}

View file

@ -0,0 +1,212 @@
import SwiftUI
// MARK: - Scripture Sheet Components
struct ScriptureSheet: View {
@Binding var scriptureText: String
@Environment(\.dismiss) private var dismiss
init(scriptureText: Binding<String>) {
self._scriptureText = scriptureText
}
var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
if scriptureText.isEmpty {
Text("No scripture text available")
.foregroundStyle(.secondary)
.padding()
} else {
let sections = formatScriptureText(scriptureText)
ForEach(Array(sections.enumerated()), id: \.offset) { index, section in
VStack(alignment: .leading, spacing: 8) {
// Verse text
Text(section.verse)
.font(.body)
.lineSpacing(4)
#if os(iOS)
.textSelection(.enabled)
#endif
// Reference
if !section.reference.isEmpty {
Text(section.reference)
.font(.caption)
.fontWeight(.medium)
.foregroundStyle(.secondary)
.padding(.top, 4)
}
}
.padding(.horizontal)
// Add divider between sections (except last)
if index < sections.count - 1 {
Divider()
.padding(.horizontal)
}
}
Spacer(minLength: 20)
}
}
.padding(.vertical)
}
.navigationTitle("Scripture Reading")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") {
dismiss()
}
}
}
}
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
}
private func formatScriptureText(_ text: String) -> [ScriptureSection] {
// Use the Rust implementation for consistent formatting across platforms
let jsonString = formatScriptureTextJson(scriptureText: text)
guard let data = jsonString.data(using: .utf8),
let sections = try? JSONDecoder().decode([ScriptureSection].self, from: data) else {
// Fallback if JSON parsing fails
return [ScriptureSection(verse: text, reference: "")]
}
return sections
}
}
// MARK: - Scripture Sheet with Sermon ID (loads data automatically)
struct ScriptureSheetForSermon: View {
let sermonId: String
@Environment(\.dismiss) private var dismiss
@State private var scriptureText: String = ""
@State private var isLoading = true
var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
if isLoading {
ProgressView("Loading scripture...")
.padding()
} else if scriptureText.isEmpty {
Text("No scripture text available for this sermon")
.foregroundStyle(.secondary)
.padding()
} else {
let sections = formatScriptureText(scriptureText)
ForEach(Array(sections.enumerated()), id: \.offset) { index, section in
VStack(alignment: .leading, spacing: 8) {
// Verse text
Text(section.verse)
.font(.body)
.lineSpacing(4)
#if os(iOS)
.textSelection(.enabled)
#endif
// Reference
if !section.reference.isEmpty {
Text(section.reference)
.font(.caption)
.fontWeight(.medium)
.foregroundStyle(.secondary)
.padding(.top, 4)
}
}
.padding(.horizontal)
// Add divider between sections (except last)
if index < sections.count - 1 {
Divider()
.padding(.horizontal)
}
}
Spacer(minLength: 20)
}
}
.padding(.vertical)
}
.navigationTitle("Scripture Reading")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") {
dismiss()
}
}
}
}
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
.task {
isLoading = true
scriptureText = fetchScriptureVersesForSermonJson(sermonId: sermonId)
isLoading = false
}
}
private func formatScriptureText(_ text: String) -> [ScriptureSection] {
// Use the Rust implementation for consistent formatting across platforms
let jsonString = formatScriptureTextJson(scriptureText: text)
guard let data = jsonString.data(using: .utf8),
let sections = try? JSONDecoder().decode([ScriptureSection].self, from: data) else {
// Fallback if JSON parsing fails
return [ScriptureSection(verse: text, reference: "")]
}
return sections
}
}
struct ScriptureSection: Codable {
let verse: String
let reference: String
}
// MARK: - Scripture Utilities
func extractScriptureReferences(from text: String) -> String {
// Use the Rust implementation for consistent parsing across platforms
return extractScriptureReferencesString(scriptureText: text)
}
// MARK: - Share Utilities
func createShareItems(for sermon: Sermon) -> [Any] {
// Use the Rust implementation for consistent share text across platforms
let jsonString = createSermonShareItemsJson(
title: sermon.title,
speaker: sermon.speaker,
videoUrl: sermon.videoUrl,
audioUrl: sermon.audioUrl
)
guard let data = jsonString.data(using: .utf8),
let shareStrings = try? JSONDecoder().decode([String].self, from: data) else {
// Fallback
return ["Check out this sermon: \"\(sermon.title)\" by \(sermon.speaker)"]
}
var items: [Any] = []
for shareString in shareStrings {
if let url = URL(string: shareString), shareString.hasPrefix("http") {
items.append(url)
} else {
items.append(shareString)
}
}
return items
}

View file

@ -0,0 +1,30 @@
import SwiftUI
struct ServiceTimeRow: View {
let day: String
let time: String
let service: String
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(day)
.font(.caption)
.foregroundStyle(.secondary)
.textCase(.uppercase)
Text(time)
.font(.system(size: horizontalSizeClass == .regular ? 18 : 16, weight: .semibold))
.foregroundStyle(Color(hex: "fb8b23"))
}
Spacer()
Text(service)
.font(.system(size: horizontalSizeClass == .regular ? 16 : 14, weight: .medium))
.foregroundStyle(.primary)
}
.padding(.vertical, 4)
}
}

454
Views/ConnectView.swift Normal file
View file

@ -0,0 +1,454 @@
import SwiftUI
import MapKit
import CoreLocation
struct ConnectView: View {
@Environment(ChurchDataService.self) private var dataService
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@State private var churchConfig: ChurchConfig?
@State private var showingContactForm = false
var body: some View {
ScrollView {
LazyVStack(spacing: 0) {
// Hero Section
VStack(spacing: 24) {
VStack(spacing: 16) {
Text(getChurchName())
.font(.system(size: horizontalSizeClass == .regular ? 42 : 32, weight: .bold, design: .serif))
.foregroundColor(.primary)
Text("Proclaiming the Three Angels' Messages")
.font(.system(size: horizontalSizeClass == .regular ? 18 : 16, weight: .medium))
.foregroundColor(Color(hex: getBrandColor()))
.italic()
}
.padding(.top, 32)
}
.padding(.bottom, 40)
// Three Angels Messages
VStack(alignment: .leading, spacing: 24) {
Text("The Three Angels' Messages")
.font(.system(size: horizontalSizeClass == .regular ? 32 : 28, weight: .bold, design: .serif))
.padding(.horizontal, 20)
Text("Our mission is centered on the prophetic messages of Revelation 14, calling people to worship God, proclaim His truth, and prepare for Christ's second coming.")
.font(.system(size: horizontalSizeClass == .regular ? 18 : 16, weight: .regular))
.lineSpacing(4)
.padding(.horizontal, 20)
.foregroundColor(.secondary)
if horizontalSizeClass == .regular {
// iPad: Horizontal layout
HStack(spacing: 16) {
ThreeAngelsMessagesView()
}
.padding(.horizontal, 20)
} else {
// iPhone: Vertical layout
VStack(spacing: 16) {
ThreeAngelsMessagesView()
}
.padding(.horizontal, 20)
}
}
.padding(.bottom, 40)
// Worship With Us Section
VStack(alignment: .leading, spacing: 16) {
Text("Worship With Us")
.font(.system(size: horizontalSizeClass == .regular ? 28 : 24, weight: .bold))
.padding(.horizontal, 20)
ServiceTimesSection()
.padding(20)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16))
.padding(.horizontal, 20)
}
.padding(.bottom, 32)
// Mission Statement
VStack(alignment: .leading, spacing: 16) {
Text("Our Mission")
.font(.system(size: horizontalSizeClass == .regular ? 28 : 24, weight: .bold))
.padding(.horizontal, 20)
Text(getAboutText())
.font(.system(size: horizontalSizeClass == .regular ? 18 : 16, weight: .regular))
.lineSpacing(4)
.padding(24)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16))
.padding(.horizontal, 20)
NavigationLink {
BeliefsView()
} label: {
HStack {
Text("Our 28 Fundamental Beliefs")
.font(.system(size: horizontalSizeClass == .regular ? 18 : 16, weight: .semibold))
Spacer()
Image(systemName: "chevron.right")
}
.foregroundColor(.white)
.padding(20)
.background(Color(hex: getBrandColor()), in: RoundedRectangle(cornerRadius: 12))
}
.padding(.horizontal, 20)
}
.padding(.bottom, 32)
// Visit & Connect Section
VStack(alignment: .leading, spacing: 16) {
Text("Visit & Connect")
.font(.system(size: horizontalSizeClass == .regular ? 28 : 24, weight: .bold))
.padding(.horizontal, 20)
// Interactive Map
ChurchMapView()
.padding(.horizontal, 20)
// Contact Information
VStack(spacing: 12) {
ContactActionRow(
icon: "location.fill",
title: "Visit Us",
subtitle: getChurchAddress(),
iconColor: .red,
action: ContactActions.directionsAction(address: getChurchAddress().replacingOccurrences(of: " ", with: "+"))
)
if UIDevice.current.userInterfaceIdiom == .phone {
ContactActionRow(
icon: "phone.fill",
title: "Call Us",
subtitle: getContactPhone(),
iconColor: .green,
action: ContactActions.callAction(phoneNumber: getContactPhone())
)
}
ContactActionRow(
icon: "envelope.fill",
title: "Email Us",
subtitle: getContactEmail(),
iconColor: .blue,
action: ContactActions.emailAction(email: getContactEmail())
)
}
.padding(20)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16))
.padding(.horizontal, 20)
// Send Message Button
Button {
showingContactForm = true
} label: {
HStack {
Image(systemName: "paperplane.fill")
Text("Send Us a Message")
.fontWeight(.semibold)
}
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding(16)
.background(Color(hex: getBrandColor()), in: RoundedRectangle(cornerRadius: 12))
}
.padding(.horizontal, 20)
}
.padding(.bottom, 32)
// Support Our Ministry Section
VStack(alignment: .leading, spacing: 16) {
Text("Support Our Ministry")
.font(.system(size: horizontalSizeClass == .regular ? 28 : 24, weight: .bold))
.padding(.horizontal, 20)
Text("Your generous gifts help us share the Three Angels' Messages and serve our community.")
.font(.system(size: horizontalSizeClass == .regular ? 18 : 16, weight: .regular))
.lineSpacing(4)
.padding(.horizontal, 20)
.foregroundColor(.secondary)
Link(destination: URL(string: getDonationUrl())!) {
HStack {
Image(systemName: "heart.fill")
Text("Give Securely Online")
.fontWeight(.semibold)
}
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding(16)
.background(.green, in: RoundedRectangle(cornerRadius: 12))
}
.padding(.horizontal, 20)
HStack {
Image(systemName: "lock.shield.fill")
.foregroundColor(.green)
Text("Secure giving powered by Adventist Giving")
.font(.caption)
.foregroundColor(.secondary)
}
.padding(.horizontal, 20)
}
.padding(.bottom, 100)
}
}
.navigationTitle("About Us")
.navigationBarTitleDisplayMode(.inline)
.onAppear {
loadChurchConfig()
}
.sheet(isPresented: $showingContactForm) {
ContactFormView(isModal: true)
.environment(dataService)
}
}
private func loadChurchConfig() {
// Use Rust functions directly - NO JSON parsing in Swift!
self.churchConfig = ChurchConfig(
contactPhone: getContactPhone(),
contactEmail: getContactEmail(),
churchAddress: getChurchAddress(),
churchName: getChurchName()
)
}
}
struct ChurchMapView: View {
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
// Church location coordinates from Rust
private var churchLocation: CLLocationCoordinate2D {
let coords = getCoordinates()
return CLLocationCoordinate2D(
latitude: coords[0],
longitude: coords[1]
)
}
@State private var cameraPosition: MapCameraPosition = .region(
MKCoordinateRegion(
center: CLLocationCoordinate2D(latitude: 41.8703594, longitude: -72.4077036),
span: MKCoordinateSpan(latitudeDelta: 0.01, longitudeDelta: 0.01)
)
)
var body: some View {
Map(position: $cameraPosition) {
Annotation("Rockville Tolland SDA Church", coordinate: churchLocation) {
VStack {
Image(systemName: "house.fill")
.font(.title2)
.foregroundColor(.white)
.padding(8)
.background(Color(hex: getBrandColor()), in: Circle())
.shadow(radius: 4)
Text("RTSDA")
.font(.caption)
.fontWeight(.bold)
.foregroundColor(.white)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.black.opacity(0.8), in: Capsule())
.shadow(radius: 3)
}
}
}
.frame(height: horizontalSizeClass == .regular ? 250 : 200)
.cornerRadius(16)
.onTapGesture {
// Open in Apple Maps
let placemark = MKPlacemark(coordinate: churchLocation)
let mapItem = MKMapItem(placemark: placemark)
mapItem.name = "Rockville Tolland SDA Church"
mapItem.openInMaps(launchOptions: [
MKLaunchOptionsDirectionsModeKey: MKLaunchOptionsDirectionsModeDriving
])
}
.onAppear {
// Update camera position when config loads
cameraPosition = .region(
MKCoordinateRegion(
center: churchLocation,
span: MKCoordinateSpan(latitudeDelta: 0.01, longitudeDelta: 0.01)
)
)
}
}
}
struct AngelMessageCard: View {
let number: Int
let title: String
let reference: String
let description: String
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
var body: some View {
VStack(spacing: 12) {
// Number circle
Text("\(number)")
.font(.system(size: horizontalSizeClass == .regular ? 28 : 24, weight: .bold))
.foregroundColor(.white)
.frame(width: horizontalSizeClass == .regular ? 50 : 44, height: horizontalSizeClass == .regular ? 50 : 44)
.background(Color(hex: getBrandColor()), in: Circle())
VStack(spacing: 8) {
Text(title)
.font(.system(size: horizontalSizeClass == .regular ? 18 : 16, weight: .semibold))
.multilineTextAlignment(.center)
Text(reference)
.font(.system(size: horizontalSizeClass == .regular ? 14 : 12, weight: .medium))
.foregroundColor(Color(hex: getBrandColor()))
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color(hex: getBrandColor()).opacity(0.1), in: Capsule())
Text(description)
.font(.system(size: horizontalSizeClass == .regular ? 15 : 13))
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.lineLimit(4)
}
}
.padding(horizontalSizeClass == .regular ? 20 : 16)
.frame(maxWidth: .infinity)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16))
}
}
struct ThreeAngelsMessagesView: View {
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@State private var messages: [(title: String, reference: String, description: String)] = [
("First Angel's Message", "Revelation 14:6-7", "Fear God and give glory to Him, for the hour of His judgment has come"),
("Second Angel's Message", "Revelation 14:8", "Babylon is fallen, is fallen, that great city"),
("Third Angel's Message", "Revelation 14:9-12", "Keep the commandments of God and the faith of Jesus")
]
@State private var isLoading = true
private let angelMessages = [
(title: "First Angel's Message", reference: "Revelation 14:6-7"),
(title: "Second Angel's Message", reference: "Revelation 14:8"),
(title: "Third Angel's Message", reference: "Revelation 14:9-12")
]
var body: some View {
ForEach(Array(messages.enumerated()), id: \.offset) { index, message in
NavigationLink {
AngelMessageDetailView(
number: index + 1,
title: message.title,
reference: message.reference
)
} label: {
AngelMessageCard(
number: index + 1,
title: message.title,
reference: message.reference,
description: message.description
)
}
.buttonStyle(PlainButtonStyle())
}
.task {
await loadMessages()
}
}
private func loadMessages() async {
var loadedMessages: [(String, String, String)] = []
for angel in angelMessages {
// Use church-core to fetch the actual Bible verse text (following BeliefsView pattern)
let versesJson = fetchBibleVerseJson(query: angel.reference)
// Use Rust function for JSON parsing and description generation - NO business logic in Swift!
let description = generateVerseDescription(versesJson: versesJson)
loadedMessages.append((angel.title, angel.reference, description))
}
await MainActor.run {
messages = loadedMessages
isLoading = false
}
}
}
struct AngelMessageDetailView: View {
let number: Int
let title: String
let reference: String
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@State private var verseText: String = ""
@State private var isLoading: Bool = true
var body: some View {
ScrollView {
VStack(spacing: 24) {
// Header with number circle
VStack(spacing: 16) {
Text("\(number)")
.font(.system(size: horizontalSizeClass == .regular ? 48 : 40, weight: .bold))
.foregroundColor(.white)
.frame(width: horizontalSizeClass == .regular ? 80 : 70, height: horizontalSizeClass == .regular ? 80 : 70)
.background(Color(hex: getBrandColor()), in: Circle())
Text(title)
.font(.system(size: horizontalSizeClass == .regular ? 32 : 28, weight: .bold, design: .serif))
.multilineTextAlignment(.center)
Text(reference)
.font(.system(size: horizontalSizeClass == .regular ? 20 : 18, weight: .medium))
.foregroundColor(Color(hex: getBrandColor()))
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color(hex: getBrandColor()).opacity(0.1), in: Capsule())
}
// Verse content
VStack(alignment: .leading, spacing: 16) {
if isLoading {
ProgressView("Loading verse...")
} else if !verseText.isEmpty {
Text(verseText)
.font(.system(size: horizontalSizeClass == .regular ? 20 : 18, weight: .regular))
.lineSpacing(6)
.padding(24)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16))
} else {
Text("Unable to load verse text")
.font(.system(size: horizontalSizeClass == .regular ? 18 : 16))
.foregroundColor(.secondary)
.padding(24)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16))
}
}
}
.padding(.horizontal, 20)
.padding(.bottom, 100)
}
.navigationTitle("Angel's Message")
.navigationBarTitleDisplayMode(.inline)
.task {
await loadVerse()
}
}
private func loadVerse() async {
let versesJson = fetchBibleVerseJson(query: reference)
await MainActor.run {
isLoading = false
// Use Rust function for JSON parsing and text extraction - NO business logic in Swift!
verseText = extractFullVerseText(versesJson: versesJson)
}
}
}

View file

@ -1,208 +1,717 @@
import SwiftUI import SwiftUI
struct ContactFormView: View { // MARK: - Rust Integration Data Models
@Environment(\.dismiss) private var dismiss
@StateObject private var viewModel = ContactFormViewModel() struct ContactFormData: Codable {
@FocusState private var focusedField: Field? let name: String
var isModal: Bool = false let email: String
let phone: String
let message: String
let subject: String
}
struct ValidationResult: Codable {
let isValid: Bool
let errors: [String]
enum Field { enum CodingKeys: String, CodingKey {
case firstName, lastName, email, phone, message case isValid = "is_valid"
case errors
}
}
struct ChurchConfig: Codable {
let contactPhone: String
let contactEmail: String
let churchAddress: String
let churchName: String
enum CodingKeys: String, CodingKey {
case contactPhone = "contact_phone"
case contactEmail = "contact_email"
case churchAddress = "church_address"
case churchName = "church_name"
}
}
struct ContactFormView: View {
let isModal: Bool
@State private var name = ""
@State private var email = ""
@State private var phone = ""
@State private var subject = "General Inquiry"
@State private var message = ""
@State private var isSubmitting = false
@State private var showingSuccess = false
@State private var showingError = false
@State private var showingAlert = false
@State private var errorMessage = ""
@State private var hasAttemptedSubmit = false
@State private var churchConfig: ChurchConfig?
@FocusState private var focusedField: Bool
private let subjectOptions = [
"General Inquiry",
"Prayer Request",
"Bible Study Interest",
"Membership Information",
"Pastoral Care",
"Adventist Youth",
"Other"
]
@Environment(\.dismiss) private var dismiss
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@Environment(ChurchDataService.self) private var dataService
init(isModal: Bool = false) {
self.isModal = isModal
} }
private var alertMessage: String {
showingSuccess ? "Thank you for reaching out! We'll get back to you as soon as possible." : errorMessage
}
private var headerSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("We'd love to hear from you! Send us a message and we'll get back to you as soon as possible.")
.font(.subheadline)
.foregroundStyle(.secondary)
.lineSpacing(4)
}
}
private var loadingOverlay: some View {
Color.black.opacity(0.3)
.ignoresSafeArea()
.overlay {
ProgressView()
.progressViewStyle(CircularProgressViewStyle())
.scaleEffect(1.5)
.tint(.orange)
}
}
var body: some View { var body: some View {
NavigationStack { NavigationStack {
Form { Form {
Section { Section {
Text("Use this form to get in touch with us for any reason - whether you have questions, need prayer, want to request Bible studies, learn more about our church, or would like to connect with our pastoral team.") headerSection
.foregroundColor(.secondary)
} }
Section { Section("Your Information") {
TextField("First Name (Required)", text: $viewModel.firstName) VStack(alignment: .leading, spacing: 4) {
.focused($focusedField, equals: .firstName) CustomTextField(title: "Name", text: $name, placeholder: "Enter your full name")
.textContentType(.givenName) .focused($focusedField)
TextField("Last Name (Required)", text: $viewModel.lastName) if let nameError = getFieldError(for: "name") {
.focused($focusedField, equals: .lastName) HStack {
.textContentType(.familyName) Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.red)
TextField("Email (Required)", text: $viewModel.email) .font(.caption)
.focused($focusedField, equals: .email) Text(nameError)
.keyboardType(.emailAddress) .font(.caption)
.textContentType(.emailAddress) .foregroundColor(.red)
.autocapitalization(.none) }
if !viewModel.email.isEmpty && !viewModel.isValidEmail(viewModel.email) { }
Text("Please enter a valid email address")
.foregroundColor(.red)
} }
TextField("Phone", text: $viewModel.phone) VStack(alignment: .leading, spacing: 4) {
.focused($focusedField, equals: .phone) CustomTextField(title: "Email", text: $email, placeholder: "Enter your email address")
.keyboardType(.phonePad) .keyboardType(.emailAddress)
.textContentType(.telephoneNumber) .autocapitalization(.none)
.onChange(of: viewModel.phone) { oldValue, newValue in .focused($focusedField)
viewModel.phone = viewModel.formatPhoneNumber(newValue)
if let emailError = getFieldError(for: "email") {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.red)
.font(.caption)
Text(emailError)
.font(.caption)
.foregroundColor(.red)
}
}
}
VStack(alignment: .leading, spacing: 4) {
PhoneTextField(
title: "Phone (Optional)",
text: $phone,
placeholder: "(555) 123-4567",
icon: "phone.fill"
)
.focused($focusedField)
if let phoneError = getFieldError(for: "phone") {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.red)
.font(.caption)
Text(phoneError)
.font(.caption)
.foregroundColor(.red)
}
} }
if !viewModel.phone.isEmpty && !viewModel.isValidPhone(viewModel.phone) {
Text("Please enter a valid phone number")
.foregroundColor(.red)
} }
} }
Section(header: Text("Message (Required)")) { Section("Subject") {
TextEditor(text: $viewModel.message) Menu {
.focused($focusedField, equals: .message) ForEach(subjectOptions, id: \.self) { option in
.frame(minHeight: 100) Button(option) {
} subject = option
}
Section {
Button(action: {
Task {
focusedField = nil // Dismiss keyboard
await viewModel.submit()
} }
}) { } label: {
HStack { HStack {
Text(subject)
.foregroundColor(.primary)
Spacer() Spacer()
Text("Submit") Image(systemName: "chevron.down")
Spacer() .foregroundColor(.secondary)
.font(.caption)
} }
} }
.disabled(!viewModel.isValid || viewModel.isSubmitting) }
Section("Message") {
VStack(alignment: .leading, spacing: 4) {
TextEditor(text: $message)
.frame(minHeight: 120)
.focused($focusedField)
if let messageError = getFieldError(for: "message") {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.red)
.font(.caption)
Text(messageError)
.font(.caption)
.foregroundColor(.red)
}
}
}
}
Section {
customSubmitButton
if hasAttemptedSubmit && !validationResult.isValid {
VStack(alignment: .leading, spacing: 8) {
ForEach(validationResult.errors, id: \.self) { error in
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.red)
Text(error)
.font(.caption)
.foregroundColor(.red)
Spacer()
}
}
}
.padding()
.background(.red.opacity(0.1), in: RoundedRectangle(cornerRadius: 8))
}
} }
} }
.navigationTitle("Contact Us") .navigationTitle(isModal ? "Contact Us" : "Get in Touch")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(isModal ? .inline : .large)
.toolbar { .toolbar {
if isModal { if isModal {
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .cancellationAction) {
Button("Done") { Button("Cancel") {
focusedField = nil
dismiss() dismiss()
} }
} }
} }
} }
.alert("Error", isPresented: .constant(viewModel.error != nil)) { }
Button("OK") { .disabled(isSubmitting)
viewModel.error = nil .overlay {
} if isSubmitting {
} message: { loadingOverlay
Text(viewModel.error ?? "")
} }
.alert("Success", isPresented: $viewModel.isSubmitted) { }
Button("OK") { .alert("Success", isPresented: $showingSuccess) {
viewModel.reset() Button("OK") {
if isModal {
dismiss()
} else {
clearForm()
} }
} message: { }
Text("Thank you for your message! We'll get back to you soon.") } message: {
Text("Thank you for reaching out! We'll get back to you as soon as possible.")
}
.alert("Error", isPresented: $showingError) {
Button("OK") { }
} message: {
Text(errorMessage)
}
.onAppear {
loadChurchConfig()
}
.onDisappear {
focusedField = false
}
}
private var customSubmitButton: some View {
Button {
submitForm()
} label: {
HStack {
if isSubmitting {
ProgressView()
.progressViewStyle(CircularProgressViewStyle())
.scaleEffect(0.8)
.tint(.white)
Text("Sending...")
} else {
Image(systemName: "paperplane.fill")
Text("Send Message")
}
}
.font(.headline)
.fontWeight(.semibold)
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
.foregroundStyle(.white)
.background(
(isFormValid && !isSubmitting) ? Color(hex: getBrandColor()) : Color(.systemGray3),
in: RoundedRectangle(cornerRadius: 12)
)
.shadow(color: isFormValid && !isSubmitting ? Color(hex: getBrandColor()).opacity(0.3) : Color.clear, radius: 4, y: 2)
}
.disabled(!isFormValid || isSubmitting)
}
private var quickContactOptions: some View {
VStack(spacing: 16) {
Divider()
.padding(.vertical, 12)
Text("Other Ways to Reach Us")
.font(.subheadline)
.fontWeight(.medium)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16)
VStack(spacing: 12) {
// Only show Call Us on iPhone - iPads can't make phone calls
if UIDevice.current.userInterfaceIdiom == .phone {
QuickContactRow(
icon: "phone.fill",
title: "Call Us",
subtitle: churchConfig?.contactPhone ?? "(860) 875-0450",
color: .green
) {
let phoneNumber = churchConfig?.contactPhone ?? "(860) 875-0450"
let digitsOnly = phoneNumber.filter { $0.isNumber }
let phoneURL = URL(string: "tel://\(digitsOnly)")
// iPhone - make the call
if let url = phoneURL, UIApplication.shared.canOpenURL(url) {
UIApplication.shared.open(url)
}
}
}
QuickContactRow(
icon: "envelope.fill",
title: "Email Us",
subtitle: getContactEmail(),
color: .blue
) {
if let url = URL(string: "mailto:\(getContactEmail())") {
UIApplication.shared.open(url)
}
}
QuickContactRow(
icon: "location.fill",
title: "Visit Us",
subtitle: "9 Hartford Turnpike, Tolland, CT",
color: .red
) {
if let url = URL(string: "https://maps.apple.com/?address=9+Hartford+Turnpike,+Tolland,+CT+06084") {
UIApplication.shared.open(url)
}
}
}
.padding(.horizontal, 16)
}
.listRowInsets(EdgeInsets())
}
private var isFormValid: Bool {
return validationResult.isValid
}
private var validationResult: ValidationResult {
let formData = ContactFormData(
name: name.trimmingCharacters(in: .whitespacesAndNewlines),
email: email.trimmingCharacters(in: .whitespacesAndNewlines),
phone: phone.trimmingCharacters(in: .whitespacesAndNewlines),
message: message.trimmingCharacters(in: .whitespacesAndNewlines),
subject: subject.trimmingCharacters(in: .whitespacesAndNewlines)
)
guard let jsonData = try? JSONEncoder().encode(formData),
let jsonString = String(data: jsonData, encoding: .utf8) else {
return ValidationResult(isValid: false, errors: ["System error"])
}
let resultJson = validateContactFormJson(formJson: jsonString)
guard let data = resultJson.data(using: .utf8),
let result = try? JSONDecoder().decode(ValidationResult.self, from: data) else {
return ValidationResult(isValid: false, errors: ["System error"])
}
return result
}
private var validationErrors: [String] {
return validationResult.errors
}
private func getFieldError(for field: String) -> String? {
// Only show errors after user has attempted to submit or field has content
guard hasAttemptedSubmit || hasFieldContent(field) else { return nil }
let errors = validationResult.errors
return errors.first { error in
switch field {
case "name":
return error.lowercased().contains("name")
case "email":
return error.lowercased().contains("email")
case "phone":
return error.lowercased().contains("phone")
case "message":
return error.lowercased().contains("message")
default:
return false
}
}
}
private func hasFieldContent(_ field: String) -> Bool {
switch field {
case "name":
return !name.isEmpty
case "email":
return !email.isEmpty
case "phone":
return !phone.isEmpty
case "message":
return !message.isEmpty
default:
return false
}
}
private func submitForm() {
hasAttemptedSubmit = true
guard isFormValid else { return }
isSubmitting = true
Task {
let success = await dataService.submitContact(
name: name.trimmingCharacters(in: .whitespacesAndNewlines),
email: email.trimmingCharacters(in: .whitespacesAndNewlines),
subject: subject.trimmingCharacters(in: .whitespacesAndNewlines),
message: message.trimmingCharacters(in: .whitespacesAndNewlines),
phone: phone.trimmingCharacters(in: .whitespacesAndNewlines)
)
await MainActor.run {
isSubmitting = false
if success {
showingSuccess = true
} else {
errorMessage = "Failed to send message. Please try again or contact us directly."
showingError = true
}
}
}
}
private func clearForm() {
name = ""
email = ""
phone = ""
subject = "General Inquiry"
message = ""
hasAttemptedSubmit = false
}
private func loadChurchConfig() {
Task {
let configJson = fetchConfigJson()
// Parse the API response structure
guard let data = configJson.data(using: .utf8),
let response = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let configData = response["data"] as? [String: Any],
let configDataJson = try? JSONSerialization.data(withJSONObject: configData),
let config = try? JSONDecoder().decode(ChurchConfig.self, from: configDataJson) else {
return
}
await MainActor.run {
self.churchConfig = config
} }
} }
} }
} }
class ContactFormViewModel: ObservableObject { // MARK: - Phone Text Field with Formatting
@Published var firstName = ""
@Published var lastName = "" // MARK: - Custom Text Field
@Published var email = "" struct CustomTextField: View {
@Published var phone = "" let title: String
@Published var message = "" @Binding var text: String
@Published var error: String? let placeholder: String
@Published var isSubmitting = false
@Published var isSubmitted = false
var isValid: Bool { var body: some View {
!firstName.isEmpty && VStack(alignment: .leading, spacing: 8) {
!lastName.isEmpty && Text(title)
!email.isEmpty && .font(.subheadline)
isValidEmail(email) && .fontWeight(.medium)
!message.isEmpty && .foregroundColor(.secondary)
(phone.isEmpty || isValidPhone(phone))
} TextField(placeholder, text: $text)
.textFieldStyle(.plain)
func reset() { .padding()
// Reset all fields .background(.background, in: RoundedRectangle(cornerRadius: 8))
firstName = "" .overlay(
lastName = "" RoundedRectangle(cornerRadius: 8)
email = "" .stroke(.quaternary, lineWidth: 1)
phone = "" )
message = ""
// Reset state
error = nil
isSubmitting = false
isSubmitted = false
}
func formatPhoneNumber(_ value: String) -> String {
// Remove all non-digits
let digits = value.replacingOccurrences(of: "[^0-9]", with: "", options: .regularExpression)
// Format the number
if digits.isEmpty {
return ""
} else if digits.count < 10 {
return digits
} else {
let areaCode = digits.prefix(3)
let middle = digits.dropFirst(3).prefix(3)
let last = digits.dropFirst(6).prefix(4)
return "(\(areaCode)) \(middle)-\(last)"
} }
} }
}
struct PhoneTextField: View {
let title: String
@Binding var text: String
let placeholder: String
let icon: String
var errorMessage: String? = nil
func isValidEmail(_ email: String) -> Bool { var body: some View {
let emailRegex = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}" VStack(alignment: .leading, spacing: 12) {
let emailPredicate = NSPredicate(format:"SELF MATCHES %@", emailRegex) HStack {
return emailPredicate.evaluate(with: email) Image(systemName: icon)
.foregroundStyle(Color(hex: getBrandColor()))
.frame(width: 20)
Text(title)
.font(.subheadline)
.fontWeight(.medium)
}
.padding(.top, 16)
.padding(.leading, 16)
TextField(placeholder, text: $text)
.keyboardType(.phonePad)
.textContentType(.telephoneNumber)
.padding(16)
.background(Color(.systemGray6), in: RoundedRectangle(cornerRadius: 8))
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(errorMessage != nil ? Color.red : Color(.systemGray4), lineWidth: errorMessage != nil ? 1.0 : 0.5)
)
.onChange(of: text) { _, newValue in
text = formatPhoneNumber(newValue)
}
if let errorMessage = errorMessage {
HStack {
Image(systemName: "exclamationmark.circle.fill")
.foregroundColor(.red)
.font(.caption)
Text(errorMessage)
.font(.caption)
.foregroundColor(.red)
}
.padding(.leading, 20)
}
}
.listRowInsets(EdgeInsets())
} }
func isValidPhone(_ phone: String) -> Bool { private func formatPhoneNumber(_ input: String) -> String {
let phoneRegex = "^\\([0-9]{3}\\) [0-9]{3}-[0-9]{4}$" // Remove all non-numeric characters
let phonePredicate = NSPredicate(format:"SELF MATCHES %@", phoneRegex) let digitsOnly = input.filter { $0.isNumber }
return phonePredicate.evaluate(with: phone)
// Don't format if too long
if digitsOnly.count > 10 {
return String(digitsOnly.prefix(10))
}
// Format based on length
switch digitsOnly.count {
case 0...3:
return digitsOnly
case 4...6:
let area = String(digitsOnly.prefix(3))
let next = String(digitsOnly.dropFirst(3))
return "(\(area)) \(next)"
case 7...10:
let area = String(digitsOnly.prefix(3))
let middle = String(digitsOnly.dropFirst(3).prefix(3))
let last = String(digitsOnly.dropFirst(6))
return "(\(area)) \(middle)-\(last)"
default:
return digitsOnly
}
} }
}
// MARK: - Custom Subject Picker
struct CustomSubjectPicker: View {
let title: String
@Binding var selection: String
let options: [String]
let icon: String
@MainActor var body: some View {
func submit() async { VStack(alignment: .leading, spacing: 8) {
guard isValid else { return } HStack {
Image(systemName: icon)
isSubmitting = true .foregroundStyle(Color(hex: getBrandColor()))
error = nil .frame(width: 20)
do { Text(title)
let url = URL(string: "https://contact.rockvilletollandsda.church/api/contact")! .font(.subheadline)
var request = URLRequest(url: url) .fontWeight(.medium)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let payload = [
"first_name": firstName,
"last_name": lastName,
"email": email,
"phone": phone,
"message": message
]
request.httpBody = try JSONSerialization.data(withJSONObject: payload)
let (_, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode) else {
throw URLError(.badServerResponse)
} }
isSubmitted = true Menu {
reset() ForEach(options, id: \.self) { option in
Button(option) {
} catch { selection = option
self.error = "There was an error submitting your message. Please try again." }
print("Error submitting form:", error) }
reset() } label: {
HStack {
Text(selection)
.foregroundStyle(.primary)
Spacer()
Image(systemName: "chevron.up.chevron.down")
.font(.caption2)
.foregroundStyle(.secondary)
}
.padding(12)
.background(Color(.systemGray6), in: RoundedRectangle(cornerRadius: 8))
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(Color(.systemGray4), lineWidth: 0.5)
)
}
} }
isSubmitting = false
} }
} }
// MARK: - Quick Contact Row
struct QuickContactRow: View {
let icon: String
let title: String
let subtitle: String
let color: Color
let action: () -> Void
var body: some View {
Button(action: action) {
HStack(spacing: 12) {
Image(systemName: icon)
.font(.title3)
.foregroundStyle(color)
.frame(width: 24)
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.subheadline)
.fontWeight(.medium)
.foregroundStyle(.primary)
Text(subtitle)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "arrow.up.right.square")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.vertical, 12)
.padding(.horizontal, 16)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 8))
}
}
// MARK: - Service Times Section
struct ServiceTimesSection: View {
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Service Times")
.font(.headline)
.fontWeight(.semibold)
VStack(spacing: 12) {
ServiceTimeRow(
day: "Saturday",
time: "9:15 AM",
service: "Sabbath School"
)
ServiceTimeRow(
day: "Saturday",
time: "11:00 AM",
service: "Worship Service"
)
ServiceTimeRow(
day: "Wednesday",
time: "6:30 PM",
service: "Prayer Meeting"
)
}
}
}
}
#Preview("Contact Form") {
NavigationStack {
ContactFormView()
.environment(ChurchDataService.shared)
}
}
#Preview("Modal Contact Form") {
NavigationStack {
ContactFormView(isModal: true)
.environment(ChurchDataService.shared)
}
}

View file

@ -1,583 +0,0 @@
import SwiftUI
import SafariServices
import AVKit
struct ContentView: View {
@State private var selectedTab = 0
var body: some View {
TabView(selection: $selectedTab) {
HomeView()
.tabItem {
Label("Home", systemImage: "house.fill")
}
.tag(0)
BulletinView()
.tabItem {
Label("Bulletin", systemImage: "newspaper.fill")
}
.tag(1)
NavigationStack {
EventsView()
}
.tabItem {
Label("Events", systemImage: "calendar")
}
.tag(2)
NavigationStack {
MessagesView()
}
.tabItem {
Label("Messages", systemImage: "video.fill")
}
.tag(3)
MoreView()
.tabItem {
Label("More", systemImage: "ellipsis")
}
.tag(4)
}
.navigationBarHidden(true)
}
}
// MARK: - Constants
enum ChurchContact {
static let email = "info@rockvilletollandsda.org"
static var emailUrl: String {
"mailto:\(email)"
}
static let phone = "860-875-0450"
static var phoneUrl: String {
"tel://\(phone.replacingOccurrences(of: "-", with: ""))"
}
static let facebook = "https://www.facebook.com/rockvilletollandsdachurch/"
}
struct HomeView: View {
@State private var scrollTarget: ScrollTarget?
@State private var showingSafariView = false
@State private var safariURL: URL?
@State private var showSheet = false
@State private var sheetContent: AnyView?
@State private var showSuccessAlert = false
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
enum ScrollTarget {
case serviceTimes
}
var body: some View {
GeometryReader { geometry in
ScrollViewReader { proxy in
ScrollView {
VStack(spacing: 0) {
// Hero Image Section
VStack(spacing: 0) {
Image("church_hero")
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: geometry.size.width)
.frame(height: horizontalSizeClass == .compact ? 350 : geometry.size.height * 0.45)
.offset(y: horizontalSizeClass == .compact ? 30 : 0)
.clipped()
.overlay(
LinearGradient(
gradient: Gradient(colors: [.clear, .black.opacity(0.5)]),
startPoint: .top,
endPoint: .bottom
)
)
}
.edgesIgnoringSafeArea(.top)
// Content Section
if horizontalSizeClass == .compact {
VStack(spacing: 16) {
quickLinksSection
aboutUsSection
}
.padding()
} else {
HStack(alignment: .top, spacing: 32) {
VStack(alignment: .leading, spacing: 24) {
quickLinksSection
.frame(maxWidth: geometry.size.width * 0.35)
Image("church_logo")
.resizable()
.scaledToFit()
.frame(width: geometry.size.width * 0.25)
.padding(.top, 24)
}
aboutUsSection
.padding(.top, 8)
}
.padding(32)
}
}
.frame(minHeight: geometry.size.height)
}
.onChange(of: scrollTarget) { _, target in
if let target {
withAnimation {
proxy.scrollTo(target, anchor: .top)
}
scrollTarget = nil
}
}
}
}
.navigationTitle("")
.toolbar {
ToolbarItem(placement: .principal) {
Image("church_logo")
.resizable()
.scaledToFit()
.frame(height: 40)
}
}
.sheet(isPresented: $showingSafariView) {
if let url = safariURL {
SafariView(url: url)
.ignoresSafeArea()
}
}
.sheet(isPresented: $showSheet) {
if let content = sheetContent {
content
}
}
.alert("Success", isPresented: $showSuccessAlert) {
Button("OK", role: .cancel) { }
} message: {
Text("Thank you for your message! We'll get back to you soon.")
}
}
private var quickLinksSection: some View {
VStack(alignment: .leading, spacing: horizontalSizeClass == .compact ? 16 : 8) {
Text("Quick Links")
.font(.custom("Montserrat-Bold", size: horizontalSizeClass == .compact ? 24 : 20))
quickLinksGrid
}
}
private var quickLinksGrid: some View {
LazyVGrid(
columns: [
GridItem(.flexible(), spacing: horizontalSizeClass == .compact ? 16 : 8),
GridItem(.flexible(), spacing: horizontalSizeClass == .compact ? 16 : 8)
],
spacing: horizontalSizeClass == .compact ? 16 : 8
) {
QuickLinkButton(title: "Contact Us", icon: "envelope.fill") {
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = windowScene.windows.first,
let rootViewController = window.rootViewController {
let contactFormView = ContactFormView(isModal: true)
let hostingController = UIHostingController(rootView: NavigationStack { contactFormView })
rootViewController.present(hostingController, animated: true)
}
}
QuickLinkButton(title: "Directions", icon: "location.fill") {
if let url = URL(string: "https://maps.apple.com/?address=9+Hartford+Turnpike,+Tolland,+CT+06084") {
UIApplication.shared.open(url)
}
}
QuickLinkButton(title: "Call Us", icon: "phone.fill") {
if let url = URL(string: ChurchContact.phoneUrl) {
UIApplication.shared.open(url)
}
}
QuickLinkButton(title: "Give Online", icon: "heart.fill") {
if let url = URL(string: "https://adventistgiving.org/donate/AN4MJG") {
UIApplication.shared.open(url, options: [:], completionHandler: nil)
}
}
}
}
private var aboutUsSection: some View {
VStack(alignment: .leading, spacing: horizontalSizeClass == .compact ? 16 : 8) {
Text("About Us")
.font(.custom("Montserrat-Bold", size: horizontalSizeClass == .compact ? 24 : 20))
aboutUsContent
}
}
private var aboutUsContent: some View {
VStack(alignment: .leading, spacing: horizontalSizeClass == .compact ? 16 : 8) {
Text("We are a vibrant, welcoming Seventh-day Adventist church community located in Tolland, Connecticut. Our mission is to share God's love through worship, fellowship, and service.")
.font(.body)
.foregroundColor(.secondary)
Divider()
.padding(.vertical, 8)
VStack(alignment: .leading, spacing: 16) {
Text("Service Times")
.font(.custom("Montserrat-Bold", size: 20))
.id(ScrollTarget.serviceTimes)
VStack(spacing: 12) {
ServiceTimeRow(day: "Saturday", time: "9:15 AM", name: "Sabbath School")
ServiceTimeRow(day: "Saturday", time: "11:00 AM", name: "Worship Service")
ServiceTimeRow(day: "Wednesday", time: "6:30 PM", name: "Prayer Meeting")
}
}
}
}
}
struct QuickLinkButton: View {
let title: String
let icon: String
var color: Color = Color(hex: "fb8b23")
var action: () -> Void
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
var body: some View {
VStack {
Image(systemName: icon)
.font(.system(size: horizontalSizeClass == .compact ? 24 : 32))
.foregroundColor(color)
Text(title)
.font(.custom("Montserrat-Medium", size: horizontalSizeClass == .compact ? 14 : 16))
.foregroundColor(.primary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.padding(horizontalSizeClass == .compact ? 16 : 24)
.background(Color(UIColor.secondarySystemBackground))
.cornerRadius(12)
.onTapGesture(perform: action)
}
}
struct ServiceTimeRow: View {
let day: String
let time: String
let name: String
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
var body: some View {
HStack {
VStack(alignment: .leading) {
Text(day)
.font(.custom("Montserrat-Regular", size: horizontalSizeClass == .compact ? 14 : 16))
.foregroundColor(.secondary)
Text(time)
.font(.custom("Montserrat-SemiBold", size: horizontalSizeClass == .compact ? 16 : 18))
}
Spacer()
Text(name)
.font(.custom("Montserrat-Regular", size: horizontalSizeClass == .compact ? 16 : 18))
.foregroundColor(.secondary)
}
.padding(.vertical, horizontalSizeClass == .compact ? 4 : 8)
}
}
struct FilterChip: View {
let title: String
let isSelected: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
Text(title)
.font(.custom("Montserrat-Medium", size: 14))
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(isSelected ? Color.accentColor : Color.clear)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(isSelected ? Color.accentColor : Color.secondary.opacity(0.5), lineWidth: 1)
)
)
.foregroundColor(isSelected ? .white : .primary)
}
.buttonStyle(.plain)
}
}
struct FilterSection: View {
let title: String
let items: [String]
let selectedItem: String?
let onSelect: (String?) -> Void
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text(title)
.font(.custom("Montserrat-SemiBold", size: 14))
.foregroundColor(.secondary)
Spacer()
if selectedItem != nil {
Button("Clear") {
onSelect(nil)
}
.font(.custom("Montserrat-Regular", size: 12))
.foregroundColor(.accentColor)
}
}
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
FilterChip(
title: "All",
isSelected: selectedItem == nil,
action: { onSelect(nil) }
)
ForEach(items, id: \.self) { item in
FilterChip(
title: item,
isSelected: selectedItem == item,
action: { onSelect(item) }
)
}
}
.padding(.bottom, 4) // Extra padding for shadow
}
}
}
}
struct FilterPicker: View {
@Binding var selectedYear: String?
@Binding var selectedMonth: String?
@Binding var selectedMediaType: JellyfinService.MediaType
let availableYears: [String]
let availableMonths: [String]
var body: some View {
VStack(spacing: 16) {
// Media Type Toggle
Picker("Media Type", selection: $selectedMediaType) {
Text("Sermons").tag(JellyfinService.MediaType.sermons)
Text("Live Archives").tag(JellyfinService.MediaType.livestreams)
}
.pickerStyle(.segmented)
// Filters
VStack(spacing: 16) {
FilterSection(
title: "YEAR",
items: availableYears,
selectedItem: selectedYear,
onSelect: { year in
selectedYear = year
selectedMonth = nil
}
)
if selectedYear != nil {
FilterSection(
title: "MONTH",
items: availableMonths,
selectedItem: selectedMonth,
onSelect: { month in
selectedMonth = month
}
)
}
}
// Active Filters Summary
if selectedYear != nil || selectedMonth != nil {
HStack {
Text("Showing:")
.font(.custom("Montserrat-Regular", size: 12))
.foregroundColor(.secondary)
if let month = selectedMonth {
Text(month)
.font(.custom("Montserrat-Medium", size: 12))
}
if let year = selectedYear {
Text(year)
.font(.custom("Montserrat-Medium", size: 12))
}
Spacer()
Button("Clear All") {
selectedYear = nil
selectedMonth = nil
}
.font(.custom("Montserrat-Medium", size: 12))
.foregroundColor(.accentColor)
}
.padding(.top, -8)
}
}
}
}
struct SafariView: UIViewControllerRepresentable {
let url: URL
@Environment(\.dismiss) private var dismiss
func makeUIViewController(context: Context) -> SFSafariViewController {
let config = SFSafariViewController.Configuration()
config.entersReaderIfAvailable = false
let controller = SFSafariViewController(url: url, configuration: config)
controller.preferredControlTintColor = UIColor(named: "AccentColor")
controller.dismissButtonStyle = .done
return controller
}
func updateUIViewController(_ uiViewController: SFSafariViewController, context: Context) {
}
}
struct MoreView: View {
@State private var showingSafariView = false
@State private var safariURL: URL?
@State private var showSheet = false
@State private var sheetContent: AnyView?
@State private var showSuccessAlert = false
var body: some View {
NavigationStack {
List {
Section("Resources") {
Button {
AppAvailabilityService.shared.openApp(
urlScheme: AppAvailabilityService.Schemes.bible,
fallbackURL: AppAvailabilityService.AppStoreURLs.bible
)
} label: {
Label("Bible", systemImage: "book.fill")
}
Button {
AppAvailabilityService.shared.openApp(
urlScheme: AppAvailabilityService.Schemes.sabbathSchool,
fallbackURL: AppAvailabilityService.AppStoreURLs.sabbathSchool
)
} label: {
Label("Sabbath School", systemImage: "book.fill")
}
Button {
AppAvailabilityService.shared.openApp(
urlScheme: AppAvailabilityService.Schemes.egw,
fallbackURL: AppAvailabilityService.AppStoreURLs.egwWritings
)
} label: {
Label("EGW Writings", systemImage: "book.closed.fill")
}
Button {
AppAvailabilityService.shared.openApp(
urlScheme: AppAvailabilityService.Schemes.hymnal,
fallbackURL: AppAvailabilityService.AppStoreURLs.hymnal
)
} label: {
Label("Adventist Hymnal", systemImage: "music.note")
}
}
Section("Connect") {
NavigationLink {
ContactFormView()
} label: {
Label("Contact Us", systemImage: "envelope.fill")
}
Link(destination: URL(string: ChurchContact.phoneUrl)!) {
Label("Call Us", systemImage: "phone.fill")
}
Link(destination: URL(string: ChurchContact.facebook)!) {
Label("Facebook", systemImage: "link")
}
Link(destination: URL(string: "https://maps.apple.com/?address=9+Hartford+Turnpike,+Tolland,+CT+06084")!) {
Label("Directions", systemImage: "map.fill")
}
}
Section("About") {
NavigationLink {
BeliefsView()
} label: {
Label("Our Beliefs", systemImage: "heart.text.square.fill")
}
}
Section("App Info") {
HStack {
Label("Version", systemImage: "info.circle.fill")
Spacer()
Text(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.0")
.foregroundColor(.secondary)
}
}
}
.navigationTitle("More")
.sheet(isPresented: $showingSafariView) {
if let url = safariURL {
SafariView(url: url)
.ignoresSafeArea()
}
}
.sheet(isPresented: $showSheet) {
if let content = sheetContent {
content
}
}
.alert("Success", isPresented: $showSuccessAlert) {
Button("OK", role: .cancel) { }
} message: {
Text("Thank you for your message! We'll get back to you soon.")
}
}
}
}
extension UIApplication {
var scrollView: UIScrollView? {
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = windowScene.windows.first {
return window.rootViewController?.view.subviews.first { $0 is UIScrollView } as? UIScrollView
}
return nil
}
}
enum NavigationDestination {
case prayerRequest
}
extension UIScrollView {
func scrollToBottom() {
let bottomPoint = CGPoint(x: 0, y: contentSize.height - bounds.size.height)
setContentOffset(bottomPoint, animated: true)
}
}
#Preview {
ContentView()
}

View file

@ -0,0 +1,794 @@
import SwiftUI
import PDFKit
struct BulletinDetailView: View {
let bulletin: ChurchBulletin
@State private var showingPDFViewer = false
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
var body: some View {
ScrollView {
VStack(spacing: 20) {
// Header Card
headerCard
// Content Sections
if !bulletin.sabbathSchool.isEmpty || !bulletin.divineWorship.isEmpty {
contentSectionsCard
}
// Scripture & Sunset Card
scriptureAndSunsetCard
// Actions Card
actionsCard
}
.padding()
}
.navigationTitle("")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbarBackground(.visible, for: .navigationBar)
.toolbarBackground(.regularMaterial, for: .navigationBar)
.sheet(isPresented: $showingPDFViewer) {
if let pdfUrl = bulletin.pdfPath, let url = URL(string: pdfUrl) {
PDFViewerSheet(url: url, title: bulletin.title)
}
}
}
// MARK: - Header Card
private var headerCard: some View {
VStack(alignment: .leading, spacing: 16) {
// Top section with icon and metadata
HStack {
Image(systemName: "doc.text.fill")
.font(.system(size: 28))
.foregroundStyle(Color(hex: "fb8b23"))
VStack(alignment: .leading, spacing: 4) {
Text("Church Bulletin")
.font(.caption)
.fontWeight(.medium)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(.green.opacity(0.1), in: Capsule())
.foregroundStyle(.green)
Text(bulletin.formattedDate)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
if bulletin.pdfPath != nil {
Image(systemName: "arrow.down.circle.fill")
.font(.title2)
.foregroundStyle(.green)
}
}
// Title
Text(bulletin.title)
.font(.title2)
.fontWeight(.bold)
.multilineTextAlignment(.leading)
// Main PDF Action
if bulletin.pdfPath != nil {
Button(action: { showingPDFViewer = true }) {
HStack {
Image(systemName: "doc.text.viewfinder")
.font(.title3)
Text("View PDF Bulletin")
.font(.headline)
.fontWeight(.semibold)
Spacer()
Image(systemName: "arrow.right.circle.fill")
.font(.title3)
}
.foregroundStyle(.white)
.padding()
.background(Color(hex: "fb8b23"), in: RoundedRectangle(cornerRadius: 12))
}
.buttonStyle(.plain)
.shadow(color: Color(hex: "fb8b23").opacity(0.3), radius: 4, x: 0, y: 2)
}
}
.padding(20)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16))
.shadow(color: .black.opacity(0.1), radius: 8, x: 0, y: 4)
}
// MARK: - Content Sections Card
private var contentSectionsCard: some View {
VStack(alignment: .leading, spacing: 20) {
// Header
HStack {
Image(systemName: "list.bullet.clipboard")
.font(.title3)
.foregroundStyle(Color(hex: "fb8b23"))
Text("Service Order")
.font(.headline)
.fontWeight(.semibold)
Spacer()
}
VStack(spacing: 16) {
if !bulletin.sabbathSchool.isEmpty {
ServiceSectionView(
title: "Sabbath School",
content: bulletin.sabbathSchool,
icon: "book.fill",
color: .blue
)
}
if !bulletin.divineWorship.isEmpty {
ServiceSectionView(
title: "Divine Worship",
content: bulletin.divineWorship,
icon: "hands.sparkles.fill",
color: .purple
)
}
}
}
.padding(20)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16))
.shadow(color: .black.opacity(0.1), radius: 8, x: 0, y: 4)
}
// MARK: - Scripture & Sunset Card
private var scriptureAndSunsetCard: some View {
VStack(spacing: 16) {
if !bulletin.scriptureReading.isEmpty {
ScriptureReadingView(scripture: bulletin.scriptureReading)
}
SunsetTimeView(sunset: bulletin.sunset)
}
.padding(20)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16))
.shadow(color: .black.opacity(0.1), radius: 8, x: 0, y: 4)
}
// MARK: - Actions Card
private var actionsCard: some View {
VStack(spacing: 16) {
HStack {
Image(systemName: "square.and.arrow.up")
.font(.title3)
.foregroundStyle(.blue)
Text("Quick Actions")
.font(.headline)
.fontWeight(.semibold)
Spacer()
}
HStack(spacing: 12) {
QuickActionButton(
title: "Share",
icon: "square.and.arrow.up",
color: .blue
) {
shareBulletin()
}
if bulletin.pdfPath != nil {
QuickActionButton(
title: "Download",
icon: "arrow.down.circle",
color: .green
) {
downloadBulletin()
}
}
QuickActionButton(
title: "Contact",
icon: "phone.fill",
color: .orange
) {
if let url = URL(string: "tel://8608750450") {
UIApplication.shared.open(url)
}
}
}
}
.padding(20)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16))
.shadow(color: .black.opacity(0.1), radius: 8, x: 0, y: 4)
}
// MARK: - Helper Functions
private func shareBulletin() {
var items: [Any] = ["Check out this week's church bulletin: \(bulletin.title)"]
if let pdfPath = bulletin.pdfPath, let url = URL(string: pdfPath) {
items.append(url)
}
#if os(iOS)
let activityVC = UIActivityViewController(activityItems: items, applicationActivities: nil)
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = windowScene.windows.first {
window.rootViewController?.present(activityVC, animated: true)
}
#endif
}
private func downloadBulletin() {
guard let pdfPath = bulletin.pdfPath, let url = URL(string: pdfPath) else { return }
// For remote PDFs, open in Safari which handles downloads properly
if url.scheme == "http" || url.scheme == "https" {
UIApplication.shared.open(url)
}
}
}
// MARK: - PDF Viewer Sheet
struct PDFViewerSheet: View {
let url: URL
let title: String
@Environment(\.dismiss) private var dismiss
@State private var isLoading = true
@State private var error: Error?
var body: some View {
NavigationStack {
ZStack {
if isLoading {
VStack(spacing: 16) {
ProgressView()
.scaleEffect(1.2)
Text("Loading PDF...")
.font(.subheadline)
.foregroundStyle(.secondary)
}
} else if let error = error {
VStack(spacing: 16) {
Image(systemName: "exclamationmark.triangle")
.font(.system(size: 48))
.foregroundStyle(.red)
Text("Error Loading PDF")
.font(.headline)
.fontWeight(.semibold)
Text(error.localizedDescription)
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
Button("Try Again") {
loadPDF()
}
.buttonStyle(.borderedProminent)
}
.padding()
} else {
PDFKitView(url: url, isLoading: $isLoading, error: $error)
}
}
.navigationTitle(title)
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Done") {
dismiss()
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Menu {
Button {
// Share PDF
} label: {
Label("Share", systemImage: "square.and.arrow.up")
}
Button {
// Download PDF
} label: {
Label("Download", systemImage: "arrow.down.circle")
}
Button {
// Print PDF
} label: {
Label("Print", systemImage: "printer")
}
} label: {
Image(systemName: "ellipsis.circle")
}
}
}
}
.onAppear {
loadPDF()
}
}
private func loadPDF() {
isLoading = true
error = nil
// Simulate loading or implement actual PDF loading logic
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
isLoading = false
}
}
}
// MARK: - PDFKit View
struct PDFKitView: UIViewRepresentable {
let url: URL
@Binding var isLoading: Bool
@Binding var error: Error?
func makeUIView(context: Context) -> PDFView {
let pdfView = PDFView()
pdfView.autoScales = true
pdfView.displayMode = .singlePageContinuous
pdfView.displayDirection = .vertical
#if os(iOS)
pdfView.backgroundColor = .systemBackground
#else
pdfView.backgroundColor = .black
#endif
// Load PDF
loadPDF(into: pdfView)
return pdfView
}
func updateUIView(_ uiView: PDFView, context: Context) {
// Update if needed
}
private func loadPDF(into pdfView: PDFView) {
Task {
do {
let (data, _) = try await URLSession.shared.data(from: url)
await MainActor.run {
if let pdfDocument = PDFDocument(data: data) {
pdfView.document = pdfDocument
self.isLoading = false
} else {
self.error = URLError(.cannotDecodeContentData)
self.isLoading = false
}
}
} catch {
await MainActor.run {
self.error = error
self.isLoading = false
}
}
}
}
}
// MARK: - Supporting View Components
struct ServiceSectionView: View {
let title: String
let content: String
let icon: String
let color: Color
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 8) {
Image(systemName: icon)
.font(.subheadline)
.foregroundStyle(color)
Text(title)
.font(.subheadline)
.fontWeight(.semibold)
.foregroundStyle(color)
Spacer()
}
HymnTextView(text: content)
.padding(.leading, 4)
}
.padding(16)
.background(color.opacity(0.05), in: RoundedRectangle(cornerRadius: 12))
}
}
struct ScriptureReadingView: View {
let scripture: String
private func parseScripture(_ text: String) -> [ScriptureVerse] {
// Use the Rust implementation for consistent scripture parsing
let jsonString = formatScriptureTextJson(scriptureText: text)
guard let data = jsonString.data(using: .utf8),
let scriptureSection = try? JSONDecoder().decode([ScriptureSection].self, from: data) else {
// Fallback to original numbered verse parsing if Rust parsing fails
return parseNumberedVerses(text)
}
var verses: [ScriptureVerse] = []
for section in scriptureSection {
// If we have both verse and reference, add them separately
if !section.reference.isEmpty {
verses.append(ScriptureVerse(number: nil, text: section.verse))
verses.append(ScriptureVerse(number: nil, text: section.reference))
} else {
// Single verse or text without reference
verses.append(ScriptureVerse(number: nil, text: section.verse))
}
}
// If still no verses found, fallback
if verses.isEmpty {
verses.append(ScriptureVerse(number: nil, text: text.trimmingCharacters(in: .whitespacesAndNewlines)))
}
return verses
}
private func parseNumberedVerses(_ text: String) -> [ScriptureVerse] {
// Fallback: Check for numbered verses like "1. text" or "1 text"
let lines = text.components(separatedBy: .newlines)
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
var verses: [ScriptureVerse] = []
for line in lines {
// Check if line starts with a number
if let firstChar = line.first, firstChar.isNumber {
let components = line.components(separatedBy: CharacterSet(charactersIn: ". "))
if components.count > 1,
let number = Int(components[0]),
components.count > 1 {
let text = components.dropFirst().joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines)
if !text.isEmpty {
verses.append(ScriptureVerse(number: number, text: text))
continue
}
}
}
// Add as regular text
verses.append(ScriptureVerse(number: nil, text: line))
}
// If no verses found, treat as single verse
if verses.isEmpty {
verses.append(ScriptureVerse(number: nil, text: text.trimmingCharacters(in: .whitespacesAndNewlines)))
}
return verses
}
var body: some View {
let verses = parseScripture(scripture)
VStack(alignment: .leading, spacing: 16) {
// Header with enhanced styling
HStack(spacing: 8) {
ZStack {
Circle()
.fill(.indigo.opacity(0.15))
.frame(width: 28, height: 28)
Image(systemName: "book.closed.fill")
.font(.caption)
.foregroundStyle(.indigo)
}
Text("Scripture Reading")
.font(.subheadline)
.fontWeight(.semibold)
.foregroundStyle(.indigo)
Spacer()
// Verse count indicator for multiple verses
if verses.count > 1 {
Text("\(verses.count) verses")
.font(.caption2)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(.indigo.opacity(0.1), in: Capsule())
.foregroundStyle(.indigo)
}
}
// Verses with enhanced styling
VStack(spacing: verses.count > 1 ? 12 : 8) {
ForEach(Array(verses.enumerated()), id: \.offset) { index, verse in
VStack(alignment: .leading, spacing: 6) {
if let number = verse.number {
HStack(spacing: 8) {
// Verse number badge
Text("\(number)")
.font(.caption)
.fontWeight(.bold)
.foregroundStyle(.white)
.frame(width: 20, height: 20)
.background(.indigo, in: Circle())
// Verse text with elegant styling
Text(verse.text)
.font(.body)
.fontWeight(.medium)
.foregroundStyle(.primary)
.multilineTextAlignment(.leading)
}
} else {
// Single verse or paragraph without numbering
HStack(alignment: .top, spacing: 8) {
// Decorative quote mark or bullet
if verses.count == 1 {
Image(systemName: "quote.opening")
.font(.caption)
.foregroundStyle(.indigo.opacity(0.6))
.padding(.top, 2)
} else {
Circle()
.fill(.indigo.opacity(0.4))
.frame(width: 4, height: 4)
.padding(.top, 8)
}
Text(verse.text)
.font(.body)
.fontWeight(.medium)
.foregroundStyle(.primary)
.multilineTextAlignment(.leading)
}
}
}
.padding(.leading, 4)
// Separator between verses (except last one)
if index < verses.count - 1 {
HStack {
Spacer()
Rectangle()
.fill(.indigo.opacity(0.2))
.frame(width: 30, height: 1)
Spacer()
}
}
}
}
}
.padding(20)
.background(.indigo.opacity(0.03), in: RoundedRectangle(cornerRadius: 16))
.overlay(
RoundedRectangle(cornerRadius: 16)
.strokeBorder(.indigo.opacity(0.15), lineWidth: 1.5)
)
.shadow(color: .indigo.opacity(0.1), radius: 4, x: 0, y: 2)
}
}
// MARK: - Scripture Verse Model
struct ScriptureVerse {
let number: Int?
let text: String
}
struct SunsetTimeView: View {
let sunset: String
var body: some View {
HStack(spacing: 12) {
Image(systemName: "sunset.fill")
.font(.title3)
.foregroundStyle(.orange)
VStack(alignment: .leading, spacing: 2) {
Text("Sabbath Ends")
.font(.caption)
.foregroundStyle(.secondary)
Text(sunset)
.font(.headline)
.fontWeight(.semibold)
}
Spacer()
Image(systemName: "clock.fill")
.font(.title3)
.foregroundStyle(.orange.opacity(0.6))
}
.padding(16)
.background(.orange.opacity(0.05), in: RoundedRectangle(cornerRadius: 12))
}
}
struct QuickActionButton: View {
let title: String
let icon: String
let color: Color
let action: () -> Void
var body: some View {
Button(action: action) {
VStack(spacing: 8) {
Image(systemName: icon)
.font(.title2)
.foregroundStyle(color)
Text(title)
.font(.caption)
.fontWeight(.medium)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
.background(color.opacity(0.1), in: RoundedRectangle(cornerRadius: 12))
}
.buttonStyle(.plain)
}
}
// MARK: - Hymn Text View with Clickable Hymn Numbers
struct HymnTextView: View {
let text: String
private func parseTextWithHymns(_ text: String) -> [(text: String, isHymn: Bool, hymnNumber: Int?)] {
var result: [(text: String, isHymn: Bool, hymnNumber: Int?)] = []
let hymnPattern = #"(?:Hymn(?:al)?\s+(?:#\s*)?|#\s*)(\d+)(?:\s+["""]([^"""]*)[""])?.*"#
guard let regex = try? NSRegularExpression(pattern: hymnPattern, options: [.caseInsensitive]) else {
return [(text: text, isHymn: false, hymnNumber: nil)]
}
let nsText = text as NSString
let matches = regex.matches(in: text, range: NSRange(location: 0, length: nsText.length))
if matches.isEmpty {
return [(text: text, isHymn: false, hymnNumber: nil)]
}
var lastIndex = 0
for match in matches {
// Add text before the hymn match
if match.range.location > lastIndex {
let textBefore = nsText.substring(with: NSRange(location: lastIndex, length: match.range.location - lastIndex))
if !textBefore.isEmpty {
result.append((text: textBefore, isHymn: false, hymnNumber: nil))
}
}
// Add hymn match
let hymnText = nsText.substring(with: match.range)
let hymnNumber = Int(nsText.substring(with: match.range(at: 1))) ?? 0
result.append((text: hymnText, isHymn: true, hymnNumber: hymnNumber))
lastIndex = match.range.location + match.range.length
}
// Add remaining text after the last match
if lastIndex < nsText.length {
let remainingText = nsText.substring(from: lastIndex)
if !remainingText.isEmpty {
result.append((text: remainingText, isHymn: false, hymnNumber: nil))
}
}
return result
}
var body: some View {
let parsedText = parseTextWithHymns(text)
if parsedText.count == 1 && !parsedText[0].isHymn {
// No hymns found, just show regular text
Text(text)
.font(.body)
} else {
// Mix of text and hymn numbers
VStack(alignment: .leading, spacing: 8) {
ForEach(Array(parsedText.enumerated()), id: \.offset) { _, segment in
if segment.isHymn, let hymnNumber = segment.hymnNumber {
Button(action: {
openHymnInAdventistHymnarium(hymnNumber)
}) {
HStack(spacing: 6) {
Image(systemName: "music.note")
.foregroundStyle(.blue)
.font(.caption)
Text(segment.text)
.font(.body)
.foregroundStyle(.blue)
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(.blue.opacity(0.1))
)
}
.buttonStyle(.plain)
} else {
Text(segment.text)
.font(.body)
}
}
}
}
}
private func openHymnInAdventistHymnarium(_ number: Int) {
// Try to open Adventist Hymnarium app first
let hymnAppURL = URL(string: "adventisthymnarium://hymn?number=\(number)")
let appStoreURL = URL(string: "https://apps.apple.com/us/app/adventist-hymnarium/id6738877733")
if let hymnAppURL = hymnAppURL, UIApplication.shared.canOpenURL(hymnAppURL) {
// Open in Adventist Hymnarium app
UIApplication.shared.open(hymnAppURL)
} else if let appStoreURL = appStoreURL {
// Offer to download the app
let alert = UIAlertController(
title: "Open in Adventist Hymnarium",
message: "Download the Adventist Hymnarium app to view Hymn #\(number)?",
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "Download App", style: .default) { _ in
UIApplication.shared.open(appStoreURL)
})
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = windowScene.windows.first,
let rootViewController = window.rootViewController {
rootViewController.present(alert, animated: true)
}
}
}
}
#Preview {
NavigationStack {
BulletinDetailView(bulletin: ChurchBulletin(
id: "1",
title: "January 11, 2025",
date: "Saturday, January 11, 2025",
sabbathSchool: "The Beatitudes - Part 3",
divineWorship: "Blessed Are Those Who Hunger",
scriptureReading: "Matthew 5:1-12",
sunset: "5:47 PM",
pdfPath: "https://example.com/bulletin.pdf",
coverImage: nil,
isActive: true
))
}
}

View file

@ -0,0 +1,307 @@
import SwiftUI
#if canImport(EventKit)
import EventKit
#endif
// MARK: - Shared Components
struct EventActionButtons: View {
let event: ChurchEvent
@Binding var showingDirections: Bool
@Binding var showingShareSheet: Bool
@Binding var showingCalendarAlert: Bool
@Binding var calendarMessage: String
let style: ActionButtonStyle
enum ActionButtonStyle {
case iphone
case ipad
}
var body: some View {
VStack(spacing: 12) {
if !event.location.isEmpty {
Button(action: { EventDetailActions.handleDirections(for: event, showingDirections: $showingDirections) }) {
HStack {
Image(systemName: EventDetailActions.isOnlineEvent(event) ? "link" : "location.fill")
Text(EventDetailActions.isOnlineEvent(event) ? "Join Event" : "Get Directions")
}
.applyButtonStyle(style)
.foregroundColor(.white)
.if(style == .ipad) { view in
view.background(Color.blue, in: Capsule())
}
.if(style == .iphone) { view in
view.background(Color.blue, in: RoundedRectangle(cornerRadius: 10))
}
}
}
#if canImport(EventKit)
Button(action: {
EventDetailActions.addToCalendar(
event: event,
showingCalendarAlert: $showingCalendarAlert,
calendarMessage: $calendarMessage
)
}) {
HStack {
Image(systemName: "calendar.badge.plus")
Text("Add to Calendar")
}
.applyButtonStyle(style)
.foregroundColor(.white)
.if(style == .ipad) { view in
view.background(Color.orange, in: Capsule())
}
.if(style == .iphone) { view in
view.background(Color.orange, in: RoundedRectangle(cornerRadius: 10))
}
}
#endif
#if os(iOS)
Button(action: {
DispatchQueue.main.async {
showingShareSheet = true
}
}) {
HStack {
Image(systemName: "square.and.arrow.up")
Text("Share Event")
}
.applyButtonStyle(style)
.foregroundColor(.primary)
.if(style == .ipad) { view in
view.background(.thickMaterial, in: Capsule())
}
.if(style == .iphone) { view in
view.background(Color.gray.opacity(0.2), in: RoundedRectangle(cornerRadius: 10))
}
}
#endif
}
}
}
// MARK: - Shared Actions
struct EventDetailActions {
static func isOnlineEvent(_ event: ChurchEvent) -> Bool {
let onlineKeywords = ["zoom", "whatsapp", "online", "virtual", "webinar", "meeting", "call"]
return onlineKeywords.contains { keyword in
event.location.lowercased().contains(keyword)
}
}
static func handleDirections(for event: ChurchEvent, showingDirections: Binding<Bool>) {
if isOnlineEvent(event) {
if let locationUrl = event.locationUrl, let url = URL(string: locationUrl) {
UIApplication.shared.open(url)
} else {
showingDirections.wrappedValue = true
}
} else {
let encodedLocation = event.location.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? ""
if let url = URL(string: "http://maps.apple.com/?q=\(encodedLocation)") {
UIApplication.shared.open(url)
}
}
}
#if canImport(EventKit)
static func addToCalendar(event: ChurchEvent, showingCalendarAlert: Binding<Bool>, calendarMessage: Binding<String>) {
let eventStore = EKEventStore()
if #available(iOS 17.0, *) {
eventStore.requestWriteOnlyAccessToEvents { granted, error in
handleCalendarAccess(granted: granted, error: error, eventStore: eventStore, event: event, showingCalendarAlert: showingCalendarAlert, calendarMessage: calendarMessage)
}
} else {
eventStore.requestAccess(to: .event) { granted, error in
handleCalendarAccess(granted: granted, error: error, eventStore: eventStore, event: event, showingCalendarAlert: showingCalendarAlert, calendarMessage: calendarMessage)
}
}
}
private static func handleCalendarAccess(granted: Bool, error: Error?, eventStore: EKEventStore, event: ChurchEvent, showingCalendarAlert: Binding<Bool>, calendarMessage: Binding<String>) {
DispatchQueue.main.async {
if granted && error == nil {
// Use Rust function to parse event data (RTSDA Architecture Rules compliance)
guard let eventJson = try? JSONEncoder().encode(event),
let jsonString = String(data: eventJson, encoding: .utf8) else {
calendarMessage.wrappedValue = "Failed to process event data."
showingCalendarAlert.wrappedValue = true
return
}
let calendarDataJson = createCalendarEventData(eventJson: jsonString)
let parsedDataJson = parseCalendarEventData(calendarJson: calendarDataJson)
guard let data = parsedDataJson.data(using: .utf8),
let calendarData = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
calendarMessage.wrappedValue = "Failed to parse calendar response"
showingCalendarAlert.wrappedValue = true
return
}
guard let success = calendarData["success"] as? Bool, success else {
let errorMsg = calendarData["error"] as? String ?? "Failed to parse event dates"
calendarMessage.wrappedValue = errorMsg
showingCalendarAlert.wrappedValue = true
return
}
let calendarEvent = EKEvent(eventStore: eventStore)
calendarEvent.title = calendarData["title"] as? String ?? event.title
calendarEvent.notes = calendarData["description"] as? String ?? event.description
calendarEvent.location = calendarData["location"] as? String ?? event.location
// Use parsed timestamps from Rust
if let startTimestamp = calendarData["start_timestamp"] as? TimeInterval,
let endTimestamp = calendarData["end_timestamp"] as? TimeInterval {
calendarEvent.startDate = Date(timeIntervalSince1970: startTimestamp)
calendarEvent.endDate = Date(timeIntervalSince1970: endTimestamp)
} else {
calendarMessage.wrappedValue = "Failed to parse event timestamps."
showingCalendarAlert.wrappedValue = true
return
}
// Add recurrence rule if event is recurring
if let hasRecurrence = calendarData["has_recurrence"] as? Bool, hasRecurrence,
let recurringType = calendarData["recurring_type"] as? String {
if let recurrenceRule = createRecurrenceRule(from: recurringType) {
calendarEvent.recurrenceRules = [recurrenceRule]
}
}
calendarEvent.calendar = eventStore.defaultCalendarForNewEvents
do {
// Use .futureEvents for recurring events, .thisEvent for single events
let span: EKSpan = (calendarData["has_recurrence"] as? Bool == true) ? .futureEvents : .thisEvent
try eventStore.save(calendarEvent, span: span)
let message = (calendarData["has_recurrence"] as? Bool == true) ?
"Recurring event series successfully added to your calendar!" :
"Event successfully added to your calendar!"
calendarMessage.wrappedValue = message
showingCalendarAlert.wrappedValue = true
} catch {
calendarMessage.wrappedValue = "Failed to add event to calendar. Please try again."
showingCalendarAlert.wrappedValue = true
}
} else {
calendarMessage.wrappedValue = "Calendar access is required to add events. Please enable it in Settings."
showingCalendarAlert.wrappedValue = true
}
}
}
private static func createRecurrenceRule(from recurringType: String) -> EKRecurrenceRule? {
switch recurringType.uppercased() {
case "DAILY":
// Daily Prayer Meeting: Sunday-Friday (exclude Saturday)
let weekdays = [
EKRecurrenceDayOfWeek(.sunday),
EKRecurrenceDayOfWeek(.monday),
EKRecurrenceDayOfWeek(.tuesday),
EKRecurrenceDayOfWeek(.wednesday),
EKRecurrenceDayOfWeek(.thursday),
EKRecurrenceDayOfWeek(.friday)
]
return EKRecurrenceRule(
recurrenceWith: .weekly,
interval: 1,
daysOfTheWeek: weekdays,
daysOfTheMonth: nil,
monthsOfTheYear: nil,
weeksOfTheYear: nil,
daysOfTheYear: nil,
setPositions: nil,
end: EKRecurrenceEnd(end: Calendar.current.date(byAdding: .year, value: 1, to: Date()) ?? Date())
)
case "WEEKLY":
return EKRecurrenceRule(
recurrenceWith: .weekly,
interval: 1,
end: EKRecurrenceEnd(end: Calendar.current.date(byAdding: .year, value: 1, to: Date()) ?? Date())
)
case "BIWEEKLY":
return EKRecurrenceRule(
recurrenceWith: .weekly,
interval: 2,
end: EKRecurrenceEnd(end: Calendar.current.date(byAdding: .year, value: 1, to: Date()) ?? Date())
)
case "MONTHLY":
return EKRecurrenceRule(
recurrenceWith: .monthly,
interval: 1,
end: EKRecurrenceEnd(end: Calendar.current.date(byAdding: .year, value: 1, to: Date()) ?? Date())
)
case "FIRST_TUESDAY":
let dayOfWeek = EKRecurrenceDayOfWeek(.tuesday)
return EKRecurrenceRule(
recurrenceWith: .monthly,
interval: 1,
daysOfTheWeek: [dayOfWeek],
daysOfTheMonth: nil,
monthsOfTheYear: nil,
weeksOfTheYear: nil,
daysOfTheYear: nil,
setPositions: [1],
end: EKRecurrenceEnd(end: Calendar.current.date(byAdding: .year, value: 1, to: Date()) ?? Date())
)
default:
return nil
}
}
#endif
static func createShareText(for event: ChurchEvent) -> String {
return """
\(event.title)
\(event.description)
📅 \(event.isMultiDay ? event.formattedDateTime : event.formattedDateRange)
🕐 \(event.formattedTime)
📍 \(event.location)
Join us at Rockville Tolland SDA Church!
"""
}
}
// MARK: - Helper Extensions
extension View {
func applyButtonStyle(_ style: EventActionButtons.ActionButtonStyle) -> AnyView {
switch style {
case .iphone:
return AnyView(
self
.frame(maxWidth: .infinity)
.padding()
)
case .ipad:
return AnyView(
self
.font(.headline)
.padding(.horizontal, 24)
.padding(.vertical, 12)
)
}
}
@ViewBuilder
func `if`<Content: View>(_ condition: Bool, transform: (Self) -> Content) -> some View {
if condition {
transform(self)
} else {
self
}
}
}

View file

@ -0,0 +1,156 @@
import SwiftUI
struct iPadEventDetailView: View {
let event: ChurchEvent
@State private var showingDirections = false
@State private var showingShareSheet = false
@State private var showingCalendarAlert = false
@State private var calendarMessage = ""
@State private var shareText = ""
var body: some View {
VStack(spacing: 0) {
// Hero Image Section - Full Width
if let imageUrl = event.image {
AsyncImage(url: URL(string: imageUrl)) { image in
image
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxHeight: 400)
} placeholder: {
Rectangle()
.fill(LinearGradient(
colors: [Color.blue.opacity(0.6), Color.purple.opacity(0.8)],
startPoint: .topLeading,
endPoint: .bottomTrailing
))
.frame(height: 400)
}
} else {
Rectangle()
.fill(LinearGradient(
colors: [Color.blue.opacity(0.6), Color.purple.opacity(0.8)],
startPoint: .topLeading,
endPoint: .bottomTrailing
))
.frame(height: 400)
}
// Title Section - Below Image
HStack {
VStack(alignment: .leading, spacing: 16) {
// Category Badge
if !event.category.isEmpty {
HStack(spacing: 8) {
Image(systemName: "tag.fill")
Text(event.category.uppercased())
.fontWeight(.bold)
}
.font(.subheadline)
.foregroundColor(.white)
.padding(.horizontal, 20)
.padding(.vertical, 10)
.background(Color(hex: "fb8b23"), in: Capsule())
}
// Title
Text(event.title)
.font(.system(size: 42, weight: .bold, design: .serif))
.foregroundColor(.primary)
.lineLimit(3)
}
Spacer()
// Action Buttons
EventActionButtons(
event: event,
showingDirections: $showingDirections,
showingShareSheet: $showingShareSheet,
showingCalendarAlert: $showingCalendarAlert,
calendarMessage: $calendarMessage,
style: .ipad
)
}
.padding(.horizontal, 40)
.padding(.vertical, 32)
.background(Color(.systemBackground))
// Simple Centered Content
VStack(spacing: 32) {
// Event Details Card - Compact and Centered
VStack(alignment: .leading, spacing: 24) {
HStack {
Image(systemName: "calendar.circle.fill")
.font(.title)
.foregroundColor(.blue)
VStack(alignment: .leading, spacing: 2) {
Text(event.formattedDate)
.font(.title2)
.fontWeight(.bold)
Text(event.detailedTimeDisplay)
.font(.subheadline)
.foregroundColor(.secondary)
}
Spacer()
}
if !event.location.isEmpty {
HStack {
Image(systemName: "location.circle.fill")
.font(.title)
.foregroundColor(.red)
Text(event.location)
.font(.title3)
.fontWeight(.semibold)
Spacer()
}
}
if !event.description.isEmpty {
Text(event.description)
.font(.body)
.lineSpacing(4)
.padding(.top, 8)
}
// Registration/contact info removed since fields no longer exist in API
}
.padding(40)
.frame(maxWidth: 600)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 20))
}
.frame(maxWidth: .infinity)
.padding(.horizontal, 60)
.padding(.bottom, 60)
}
.onAppear {
shareText = EventDetailActions.createShareText(for: event)
}
.sheet(isPresented: $showingDirections) {
Text("Directions to \(event.location)")
}
#if os(iOS)
.sheet(isPresented: $showingShareSheet) {
ShareSheet(activityItems: [shareText])
}
#endif
.alert("Calendar", isPresented: $showingCalendarAlert) {
Button("OK") { }
} message: {
Text(calendarMessage)
}
}
}
#Preview {
iPadEventDetailView(event: ChurchEvent.sampleEvent())
}

View file

@ -0,0 +1,131 @@
import SwiftUI
struct EventDetailView: View {
let event: ChurchEvent
@State private var showingDirections = false
@State private var showingShareSheet = false
@State private var showingCalendarAlert = false
@State private var calendarMessage = ""
@State private var shareText = ""
var body: some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: 20) {
// Event Image
if let imageUrl = event.image {
AsyncImage(url: URL(string: imageUrl)) { image in
image
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxHeight: 200)
} placeholder: {
Rectangle()
.fill(LinearGradient(
colors: [Color.blue.opacity(0.6), Color.purple.opacity(0.8)],
startPoint: .topLeading,
endPoint: .bottomTrailing
))
.frame(height: 200)
}
.cornerRadius(12)
}
VStack(alignment: .leading, spacing: 16) {
// Title and Category
VStack(alignment: .leading, spacing: 8) {
if !event.category.isEmpty {
Text(event.category.uppercased())
.font(.caption)
.fontWeight(.bold)
.foregroundColor(.blue)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.blue.opacity(0.1))
.cornerRadius(8)
}
Text(event.title)
.font(.largeTitle)
.fontWeight(.bold)
.lineLimit(nil)
Text(event.description)
.font(.body)
.foregroundColor(.secondary)
.lineLimit(nil)
}
Divider()
// Event Details
VStack(alignment: .leading, spacing: 12) {
HStack {
Image(systemName: "calendar")
.foregroundColor(.blue)
.frame(width: 20)
Text(event.formattedDate)
.font(.body)
}
HStack {
Image(systemName: "clock")
.foregroundColor(.blue)
.frame(width: 20)
Text(event.detailedTimeDisplay)
.font(.body)
}
if !event.location.isEmpty {
HStack {
Image(systemName: "location.fill")
.foregroundColor(.blue)
.frame(width: 20)
Text(event.location)
.font(.body)
.lineLimit(nil)
}
}
}
Divider()
// Action Buttons
EventActionButtons(
event: event,
showingDirections: $showingDirections,
showingShareSheet: $showingShareSheet,
showingCalendarAlert: $showingCalendarAlert,
calendarMessage: $calendarMessage,
style: .iphone
)
}
.padding(16)
.padding(.bottom, 100)
}
}
.ignoresSafeArea(.keyboard)
.navigationBarTitleDisplayMode(.inline)
.onAppear {
shareText = EventDetailActions.createShareText(for: event)
}
.sheet(isPresented: $showingDirections) {
Text("Directions to \(event.location)")
}
#if os(iOS)
.sheet(isPresented: $showingShareSheet) {
ShareSheet(activityItems: [shareText])
}
#endif
.alert("Calendar", isPresented: $showingCalendarAlert) {
Button("OK") { }
} message: {
Text(calendarMessage)
}
}
}
#Preview {
NavigationStack {
EventDetailView(event: ChurchEvent.sampleEvent())
}
}

View file

@ -0,0 +1,20 @@
import SwiftUI
/// Smart wrapper that automatically selects the appropriate EventDetailView based on device
struct EventDetailViewWrapper: View {
let event: ChurchEvent
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
var body: some View {
if horizontalSizeClass == .regular {
// iPad: Use space-optimized layout
iPadEventDetailView(event: event)
} else {
// iPhone: Use single-column layout
EventDetailView(event: event)
}
}
}
// MARK: - Convenience typealias for backward compatibility
typealias EventDetailView_Old = EventDetailViewWrapper

View file

@ -1,74 +0,0 @@
import SwiftUI
struct EventCard: View {
let event: Event
let action: () -> Void
var body: some View {
Button(action: action) {
VStack(alignment: .center, spacing: 12) {
if let imageURL = event.imageURL {
CachedAsyncImage(url: imageURL) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
} placeholder: {
Color.gray.opacity(0.2)
}
.frame(height: 160)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
VStack(alignment: .center, spacing: 8) {
Text(event.title)
.font(.headline)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
HStack(spacing: 8) {
Text(event.category.rawValue)
.font(.caption)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.blue.opacity(0.1))
.clipShape(Capsule())
if event.reoccuring != .none {
Text(event.reoccuring.rawValue.replacingOccurrences(of: "_", with: " ").capitalized)
.font(.caption)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.purple.opacity(0.1))
.clipShape(Capsule())
}
}
.fixedSize(horizontal: true, vertical: false)
HStack {
Image(systemName: "calendar")
Text(event.formattedDateTime)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
}
.font(.subheadline)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
}
.padding()
.background(Color(.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 16))
.contentShape(RoundedRectangle(cornerRadius: 16))
.shadow(color: .black.opacity(0.1), radius: 8, x: 0, y: 2)
}
.buttonStyle(PlainButtonStyle())
}
}
struct EventCardButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.scaleEffect(configuration.isPressed ? 0.98 : 1.0)
.animation(.easeInOut(duration: 0.2), value: configuration.isPressed)
}
}

View file

@ -1,149 +0,0 @@
import SwiftUI
struct EventDetailView: View {
let event: Event
@Environment(\.dismiss) var dismiss
@State private var showingAlert = false
@State private var alertTitle = ""
@State private var alertMessage = ""
var body: some View {
ScrollView {
VStack(alignment: .center, spacing: 20) {
// Header with dismiss button
HStack {
Button("Done") {
dismiss()
}
.padding()
Spacer()
}
// Image if available
if let imageURL = event.imageURL {
AsyncImage(url: imageURL) { image in
image
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxHeight: 200)
} placeholder: {
Color.gray.opacity(0.2)
.frame(height: 200)
}
}
VStack(alignment: .center, spacing: 16) {
// Title and tags
Text(event.title)
.font(.title2.bold())
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
HStack(spacing: 8) {
Text(event.category.rawValue)
.font(.caption)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.blue.opacity(0.1))
.clipShape(Capsule())
if event.reoccuring != .none {
Text(event.reoccuring.rawValue.replacingOccurrences(of: "_", with: " ").capitalized)
.font(.caption)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.purple.opacity(0.1))
.clipShape(Capsule())
}
}
// Date and location
VStack(spacing: 12) {
HStack {
Image(systemName: "calendar")
Text(event.formattedDateTime)
}
.font(.subheadline)
.foregroundStyle(.secondary)
if event.hasLocation {
Button {
Task {
await event.openInMaps()
}
} label: {
HStack {
Image(systemName: "mappin.and.ellipse")
Text(event.displayLocation)
.multilineTextAlignment(.center)
}
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
}
// Description
if !event.plainDescription.isEmpty {
let lines = event.plainDescription.components(separatedBy: .newlines)
VStack(alignment: .center, spacing: 4) {
ForEach(lines, id: \.self) { line in
if line.starts(with: "📞") {
Button {
Task {
event.callPhone()
}
} label: {
Text(line)
.font(.body)
.multilineTextAlignment(.center)
.foregroundStyle(.blue)
}
} else {
Text(line)
.font(.body)
.multilineTextAlignment(.center)
}
}
}
.fixedSize(horizontal: false, vertical: true)
.padding(.top, 8)
}
// Add to Calendar button
Button {
Task {
await event.addToCalendar { success, error in
if success {
alertTitle = "Success"
alertMessage = "Event has been added to your calendar"
} else {
alertTitle = "Error"
alertMessage = error?.localizedDescription ?? "Failed to add event to calendar"
}
showingAlert = true
}
}
} label: {
Label("Add to Calendar", systemImage: "calendar.badge.plus")
.font(.headline)
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding()
.background(Color.blue)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.padding(.top, 16)
}
.padding(.horizontal)
}
}
.background(Color(.systemBackground))
.alert(alertTitle, isPresented: $showingAlert) {
Button("OK", role: .cancel) { }
} message: {
Text(alertMessage)
}
}
}

292
Views/EventsListView.swift Normal file
View file

@ -0,0 +1,292 @@
import SwiftUI
struct EventsListView: View {
@Environment(ChurchDataService.self) private var dataService
@State private var searchText = ""
@State private var selectedCategory = "All"
@State private var showingFilters = false
private let categories = ["All", "Service", "Ministry", "Social", "Other"]
private var grayBackgroundColor: Color {
#if os(iOS)
Color(.systemGray6)
#else
Color.gray.opacity(0.2)
#endif
}
private var systemBackgroundColor: Color {
#if os(iOS)
Color(.systemBackground)
#else
Color.black
#endif
}
var filteredEvents: [ChurchEvent] {
var events = dataService.events
// Filter by search text
events = SearchUtils.searchEvents(events, searchText: searchText)
// Filter by category
if selectedCategory != "All" {
events = events.filter { $0.category.lowercased() == selectedCategory.lowercased() }
}
return events
}
var body: some View {
VStack(spacing: 0) {
// Search and Filter Bar
VStack(spacing: 12) {
// Search Bar
HStack {
Image(systemName: "magnifyingglass")
.foregroundColor(.secondary)
TextField("Search events...", text: $searchText)
.textFieldStyle(PlainTextFieldStyle())
if !searchText.isEmpty {
Button(action: { searchText = "" }) {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.secondary)
}
}
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(grayBackgroundColor)
.cornerRadius(10)
// Category Filter
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(categories, id: \.self) { category in
Button(action: {
selectedCategory = category
}) {
Text(category)
.font(.subheadline)
.fontWeight(.medium)
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(selectedCategory == category ? Color.blue : grayBackgroundColor)
.foregroundColor(selectedCategory == category ? .white : .primary)
.cornerRadius(20)
}
}
}
.padding(.horizontal, 20)
}
}
.padding(.horizontal, 20)
.padding(.top, 8)
.padding(.bottom, 16)
.background(systemBackgroundColor)
// Events List
if dataService.isLoading {
Spacer()
ProgressView("Loading events...")
Spacer()
} else if filteredEvents.isEmpty {
Spacer()
VStack(spacing: 16) {
Image(systemName: "calendar.badge.exclamationmark")
.font(.system(size: 50))
.foregroundColor(.secondary)
Text("No events found")
.font(.headline)
.foregroundColor(.secondary)
if !searchText.isEmpty || selectedCategory != "All" {
Text("Try adjusting your search or filters")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
}
.padding()
Spacer()
} else {
ScrollView {
LazyVStack(spacing: 16) {
ForEach(filteredEvents) { event in
NavigationLink(destination: EventDetailViewWrapper(event: event)
.environment(dataService)) {
EventListCard(event: event)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 20)
.padding(.bottom, 100)
}
.refreshable {
await dataService.loadAllEvents()
}
}
}
.navigationTitle("Events")
#if os(iOS)
.navigationBarTitleDisplayMode(.large)
#endif
.task {
await dataService.loadAllEvents()
}
}
}
// MARK: - Event List Card Component
struct EventListCard: View {
let event: ChurchEvent
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
private var systemBackgroundColor: Color {
#if os(iOS)
Color(.systemBackground)
#else
Color.black
#endif
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
// Hero Image or Gradient
Group {
if let imageUrl = event.image, !imageUrl.isEmpty {
CachedAsyncImage(url: URL(string: imageUrl)) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
} placeholder: {
gradientBackground
}
} else {
gradientBackground
}
}
.frame(height: horizontalSizeClass == .regular ? 180 : 140)
.clipped()
.overlay(
// Category Badge
VStack {
HStack {
if !event.category.isEmpty {
Text(event.category.uppercased())
.font(.caption2)
.fontWeight(.bold)
.foregroundColor(.white)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(.ultraThinMaterial)
.cornerRadius(6)
}
Spacer()
if event.isFeatured {
Image(systemName: "star.fill")
.foregroundColor(.yellow)
.font(.caption)
}
}
Spacer()
}
.padding(12)
)
// Event Details
VStack(alignment: .leading, spacing: 8) {
Text(event.title)
.font(.headline)
.fontWeight(.semibold)
.lineLimit(2)
.multilineTextAlignment(.leading)
// Date and Time
HStack(spacing: 4) {
Image(systemName: "calendar")
.foregroundColor(.blue)
.font(.caption)
if event.isMultiDay {
// Multi-day: show formatted time which contains "Aug 30 - Aug 31 at 6 PM"
Text(event.formattedTime)
.font(.subheadline)
.foregroundColor(.secondary)
} else {
// Single day: show date and time separately
Text(event.formattedDateRange)
.font(.subheadline)
.foregroundColor(.secondary)
Text("")
.foregroundColor(.secondary)
.font(.caption)
Text(event.formattedTime)
.font(.subheadline)
.foregroundColor(.secondary)
}
}
// Location
if !event.location.isEmpty {
HStack(spacing: 4) {
Image(systemName: "location")
.foregroundColor(.blue)
.font(.caption)
Text(event.location)
.font(.subheadline)
.foregroundColor(.secondary)
.lineLimit(1)
}
}
// Recurring indicator
if let recurringType = event.recurringType, !recurringType.isEmpty {
HStack(spacing: 4) {
Image(systemName: "repeat")
.foregroundColor(.orange)
.font(.caption)
Text(recurringType.capitalized)
.font(.caption)
.foregroundColor(.orange)
.fontWeight(.medium)
}
}
}
.padding(16)
}
.background(systemBackgroundColor)
.cornerRadius(12)
.shadow(color: .black.opacity(0.1), radius: 4, x: 0, y: 2)
.contentShape(RoundedRectangle(cornerRadius: 12))
}
private var gradientBackground: some View {
LinearGradient(
colors: [
Color.blue.opacity(0.7),
Color.purple.opacity(0.6)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
}
}
#Preview {
NavigationStack {
EventsListView()
.environment(ChurchDataService.shared)
}
}

View file

@ -1,60 +0,0 @@
import SwiftUI
struct EventsView: View {
@StateObject private var viewModel = EventsViewModel()
@State private var selectedEvent: Event?
var body: some View {
NavigationStack {
Group {
if viewModel.isLoading {
ProgressView()
} else if let error = viewModel.error {
VStack(spacing: 16) {
Text("Unable to load events")
.font(.headline)
Text(error.localizedDescription)
.font(.subheadline)
.foregroundStyle(.secondary)
Button("Try Again") {
Task {
await viewModel.loadEvents()
}
}
.buttonStyle(.bordered)
}
} else if viewModel.events.isEmpty {
Text("No upcoming events")
.font(.headline)
.foregroundStyle(.secondary)
} else {
ScrollView {
LazyVStack(spacing: 24) {
ForEach(viewModel.events) { event in
EventCard(event: event) {
selectedEvent = event
}
.padding(.horizontal)
}
}
.padding(.vertical)
}
.refreshable {
await viewModel.loadEvents()
}
}
}
.navigationTitle("Events")
.sheet(item: $selectedEvent) { event in
EventDetailView(event: event)
}
.task {
await viewModel.loadEvents()
}
}
}
}
#Preview {
EventsView()
}

236
Views/FilterSheet.swift Normal file
View file

@ -0,0 +1,236 @@
import SwiftUI
struct FilterSheet: View {
let selectedTab: WatchView.WatchTab
@Binding var selectedSpeaker: String
@Binding var selectedDateRange: String
@Binding var selectedDuration: String
@Environment(\.dismiss) private var dismiss
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@Environment(ChurchDataService.self) private var dataService
@State private var selectedYear: String = "All Time"
@State private var selectedMonth: String = "All Months"
private var availableSpeakers: [String] {
let currentContent = selectedTab == .sermons ? dataService.sermons : dataService.livestreamArchives
let cleanedSpeakers = currentContent.compactMap { sermon -> String? in
let speaker = sermon.speaker.trimmingCharacters(in: .whitespacesAndNewlines)
return speaker.isEmpty ? nil : speaker
}
let uniqueSpeakers = Set(cleanedSpeakers)
return ["All Speakers"] + Array(uniqueSpeakers).sorted()
}
private var availableYears: [String] {
var years = ["All Time"]
let calendar = Calendar.current
// Use proper date formatter for your date format: "August 02, 2025"
let formatter = DateFormatter()
formatter.dateFormat = "MMMM dd, yyyy"
formatter.locale = Locale(identifier: "en_US")
let currentContent = selectedTab == .sermons ? dataService.sermons : dataService.livestreamArchives
// Extract all unique years from content
var yearSet: Set<Int> = []
for item in currentContent {
guard let dateString = item.date,
!dateString.isEmpty,
let date = formatter.date(from: dateString) else { continue }
let year = calendar.component(.year, from: date)
yearSet.insert(year)
}
// Add years in descending order
let sortedYears = yearSet.sorted(by: >)
for year in sortedYears {
years.append(String(year))
}
return years
}
private var availableMonths: [String] {
guard selectedYear != "All Time", let targetYear = Int(selectedYear) else {
return ["All Months"]
}
var months = ["All Months"]
let calendar = Calendar.current
// Use proper date formatter for your date format: "August 02, 2025"
let formatter = DateFormatter()
formatter.dateFormat = "MMMM dd, yyyy"
formatter.locale = Locale(identifier: "en_US")
let currentContent = selectedTab == .sermons ? dataService.sermons : dataService.livestreamArchives
// Extract months for the selected year
var monthSet: Set<Int> = []
for item in currentContent {
guard let dateString = item.date,
!dateString.isEmpty,
let date = formatter.date(from: dateString) else { continue }
let year = calendar.component(.year, from: date)
let month = calendar.component(.month, from: date)
if year == targetYear {
monthSet.insert(month)
}
}
// Add months in descending order (most recent first)
let monthNames = ["January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December"]
let sortedMonths = monthSet.sorted(by: >)
for month in sortedMonths {
if month >= 1 && month <= 12 {
months.append(monthNames[month-1])
}
}
print("🔍 DEBUG: availableMonths for year \(targetYear): \(months)")
return months
}
private let durations = ["Any Duration", "Under 30 min", "30-60 min", "Over 60 min"]
var body: some View {
NavigationStack {
Form {
Section {
Text("Filter \(selectedTab.rawValue)")
.font(.headline)
.foregroundColor(.primary)
} header: {
EmptyView()
}
Section("Speaker") {
Picker("Speaker", selection: $selectedSpeaker) {
ForEach(availableSpeakers, id: \.self) { speaker in
Text(speaker).tag(speaker)
}
}
.pickerStyle(.menu)
}
Section("Date Range") {
Picker("Year", selection: $selectedYear) {
ForEach(availableYears, id: \.self) { year in
Text(year).tag(year)
}
}
.pickerStyle(.menu)
.onChange(of: selectedYear) { _, newYear in
// Don't auto-reset month when year changes during initialization
// Only reset if it's a user-initiated change
if selectedMonth != "All Months" && !availableMonths.contains(selectedMonth) {
selectedMonth = "All Months"
}
}
// Only show month picker if a specific year is selected
if selectedYear != "All Time" {
Picker("Month", selection: $selectedMonth) {
ForEach(availableMonths, id: \.self) { month in
Text(month).tag(month)
}
}
.pickerStyle(.menu)
}
}
Section("Duration") {
Picker("Duration", selection: $selectedDuration) {
ForEach(durations, id: \.self) { duration in
Text(duration).tag(duration)
}
}
.pickerStyle(.menu)
}
Section {
Button("Reset Filters") {
selectedSpeaker = "All Speakers"
selectedYear = "All Time"
selectedMonth = "All Months"
selectedDuration = "Any Duration"
}
.foregroundColor(.red)
}
}
.navigationTitle("Filters")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Apply") {
// Convert year/month selection to selectedDateRange format
if selectedYear == "All Time" {
selectedDateRange = "All Time"
} else if selectedMonth == "All Months" {
selectedDateRange = selectedYear
} else {
selectedDateRange = "\(selectedMonth) \(selectedYear)"
}
dismiss()
}
.fontWeight(.semibold)
.foregroundColor(Color(hex: "fb8b23"))
}
}
}
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
.onAppear {
// Initialize year/month from current selectedDateRange
print("🔍 DEBUG: Initializing filter sheet with selectedDateRange: '\(selectedDateRange)'")
if selectedDateRange == "All Time" {
selectedYear = "All Time"
selectedMonth = "All Months"
} else {
// Check if it's a year-only filter (e.g., "2024")
if let _ = Int(selectedDateRange) {
selectedYear = selectedDateRange
selectedMonth = "All Months"
print("🔍 DEBUG: Set year-only filter - Year: \(selectedYear), Month: \(selectedMonth)")
} else {
// It's a month-year filter (e.g., "January 2024")
let components = selectedDateRange.components(separatedBy: " ")
if components.count == 2 {
selectedYear = components[1]
selectedMonth = components[0]
print("🔍 DEBUG: Set month-year filter - Year: \(selectedYear), Month: \(selectedMonth)")
// Force a small delay to ensure availableMonths is computed before validation
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
if !availableMonths.contains(selectedMonth) {
print("🔍 DEBUG: Month '\(selectedMonth)' not found in available months, resetting")
selectedMonth = "All Months"
} else {
print("🔍 DEBUG: Month '\(selectedMonth)' validated successfully")
}
}
} else {
selectedYear = "All Time"
selectedMonth = "All Months"
print("🔍 DEBUG: Failed to parse, defaulting to All Time")
}
}
}
}
}
}

450
Views/HomeFeedView.swift Normal file
View file

@ -0,0 +1,450 @@
import SwiftUI
struct HomeFeedView: View {
@Environment(ChurchDataService.self) private var dataService
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
var body: some View {
if horizontalSizeClass == .regular {
// iPad: Enhanced entertainment-focused layout
iPadHomeFeedView()
} else {
// iPhone: Current compact layout
iPhoneHomeFeedView()
}
}
}
struct iPadHomeFeedView: View {
@Environment(ChurchDataService.self) private var dataService
var body: some View {
ScrollView {
LazyVStack(spacing: 32) {
// Hero Section - Latest Featured Sermon
if let latestSermon = dataService.sermons.first {
VStack(alignment: .leading, spacing: 0) {
HStack {
VStack(alignment: .leading, spacing: 8) {
Text("LATEST MESSAGE")
.font(.caption)
.fontWeight(.bold)
.foregroundColor(Color(hex: getBrandColor()))
.textCase(.uppercase)
.tracking(1)
Text("Featured Sermon")
.font(.system(size: 32, weight: .bold))
.foregroundColor(.primary)
}
Spacer()
NavigationLink("Browse All") {
WatchView()
}
.font(.system(size: 16, weight: .medium))
.foregroundColor(Color(hex: getBrandColor()))
}
.padding(.horizontal, 24)
.padding(.top, 16)
Button {
if latestSermon.videoUrl != nil, let url = URL(string: getOptimalStreamingUrl(mediaId: latestSermon.id)) {
SharedVideoManager.shared.playVideo(url: url, title: latestSermon.title, artworkURL: latestSermon.thumbnail)
}
} label: {
HeroSermonCard(sermon: latestSermon)
}
.buttonStyle(PlainButtonStyle())
.padding(.top, 20)
}
}
// Recent Sermons Grid
if dataService.sermons.count > 1 {
VStack(alignment: .leading, spacing: 20) {
HStack {
Text("Recent Sermons")
.font(.system(size: 28, weight: .bold))
Spacer()
NavigationLink("View All") {
WatchView()
}
.font(.system(size: 16, weight: .medium))
.foregroundColor(Color(hex: getBrandColor()))
}
.padding(.horizontal, 24)
// 2x2 grid layout for iPads
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 16), count: 2), spacing: 16) {
ForEach(Array(dataService.sermons.dropFirst().prefix(4)), id: \.id) { sermon in
Button {
if sermon.videoUrl != nil, let url = URL(string: getOptimalStreamingUrl(mediaId: sermon.id)) {
SharedVideoManager.shared.playVideo(url: url, title: sermon.title, artworkURL: sermon.thumbnail)
}
} label: {
SermonGridCard(sermon: sermon)
}
.buttonStyle(PlainButtonStyle())
}
}
.padding(.horizontal, 24)
}
}
// Upcoming Events Section
if !dataService.events.isEmpty {
VStack(alignment: .leading, spacing: 20) {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("WHAT'S HAPPENING")
.font(.caption)
.fontWeight(.bold)
.foregroundColor(Color(hex: getBrandColor()))
.textCase(.uppercase)
.tracking(1)
Text("Upcoming Events")
.font(.system(size: 28, weight: .bold))
}
Spacer()
NavigationLink("All Events") {
EventsListView()
}
.font(.system(size: 16, weight: .medium))
.foregroundColor(Color(hex: getBrandColor()))
}
.padding(.horizontal, 24)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 16) {
ForEach(dataService.events.prefix(5), id: \.id) { event in
NavigationLink {
EventDetailViewWrapper(event: event)
} label: {
ChurchEventHighlightCard(event: event)
.frame(width: 300)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 24)
}
}
}
// Quick Actions removed - functionality available via main navigation tabs
}
.padding(.vertical, 20)
.padding(.bottom, 100)
}
.navigationTitle("Welcome to RTSDA")
.navigationBarTitleDisplayMode(.large)
.refreshable {
await dataService.loadHomeFeed()
}
}
}
struct iPhoneHomeFeedView: View {
@Environment(ChurchDataService.self) private var dataService
var body: some View {
ScrollView {
LazyVStack(spacing: 20) {
if !dataService.sermons.isEmpty {
VStack(alignment: .leading, spacing: 16) {
HStack {
Text("Featured Sermons")
.font(.system(size: 24, weight: .bold))
Spacer()
NavigationLink("See All") {
WatchView()
}
.font(.subheadline)
.foregroundColor(.blue)
}
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 16) {
ForEach(dataService.sermons.prefix(5), id: \.id) { sermon in
SermonCard(sermon: sermon, style: .feed)
.frame(width: 250)
}
}
.padding(.horizontal, 20)
}
}
}
if !dataService.events.isEmpty {
VStack(alignment: .leading, spacing: 16) {
HStack {
Text("Upcoming Events")
.font(.system(size: 24, weight: .bold))
Spacer()
NavigationLink("See All") {
EventsListView()
}
.font(.subheadline)
.foregroundColor(.blue)
}
ForEach(dataService.events.prefix(3), id: \.id) { event in
FeedItemCard(item: FeedItem(type: .event(event), timestamp: Date()))
}
}
}
}
.padding(.horizontal, 20)
.padding(.bottom, 100)
}
.navigationTitle("Welcome to RTSDA")
.navigationBarTitleDisplayMode(.large)
.refreshable {
await dataService.loadHomeFeed()
}
}
}
// MARK: - iPad-specific Cards
struct HeroSermonCard: View {
let sermon: Sermon
var body: some View {
ZStack {
// Background image with gradient overlay
AsyncImage(url: URL(string: sermon.thumbnail ?? "")) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
.offset(y: 20) // Shift DOWN to show heads instead of cutting them off
.scaleEffect(0.9) // Zoom out to ensure heads are visible
} placeholder: {
Rectangle()
.fill(LinearGradient(
colors: [Color(hex: getBrandColor()), Color(hex: getBrandColor()).opacity(0.7)],
startPoint: .topLeading,
endPoint: .bottomTrailing
))
}
.frame(height: 450)
.clipped()
// Gradient overlay
LinearGradient(
colors: [Color.clear, Color.black.opacity(0.8)],
startPoint: .top,
endPoint: .bottom
)
// Content overlay
VStack(alignment: .leading) {
Spacer()
HStack {
VStack(alignment: .leading, spacing: 12) {
// Play button
Image(systemName: "play.circle.fill")
.font(.system(size: 60))
.foregroundColor(.white)
.shadow(color: .black.opacity(0.3), radius: 4)
VStack(alignment: .leading, spacing: 8) {
Text(sermon.title)
.font(.system(size: 28, weight: .bold))
.foregroundColor(.white)
.lineLimit(2)
.shadow(color: .black.opacity(0.5), radius: 2)
HStack(spacing: 16) {
if !sermon.speaker.isEmpty {
HStack(spacing: 4) {
Image(systemName: "person.fill")
.font(.caption)
Text(sermon.speaker)
.font(.subheadline)
.fontWeight(.medium)
}
}
if let duration = sermon.durationFormatted {
HStack(spacing: 4) {
Image(systemName: "clock.fill")
.font(.caption)
Text(duration)
.font(.subheadline)
}
}
Text(sermon.formattedDate)
.font(.subheadline)
}
.foregroundColor(.white.opacity(0.9))
.shadow(color: .black.opacity(0.3), radius: 1)
}
}
Spacer()
}
.padding(32)
}
}
.cornerRadius(16)
.shadow(color: .black.opacity(0.1), radius: 8, x: 0, y: 4)
.padding(.horizontal, 24)
}
}
struct SermonGridCard: View {
let sermon: Sermon
var body: some View {
VStack(alignment: .leading, spacing: 12) {
// Thumbnail
ZStack {
AsyncImage(url: URL(string: sermon.thumbnail ?? "")) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
.offset(y: 10) // Shift DOWN to show heads instead of cutting them off
.scaleEffect(0.95) // Zoom out to ensure heads are visible
} placeholder: {
Rectangle()
.fill(LinearGradient(
colors: [Color(hex: getBrandColor()), Color(hex: getBrandColor()).opacity(0.7)],
startPoint: .topLeading,
endPoint: .bottomTrailing
))
}
.frame(height: 200)
.clipped()
// Play button overlay
Circle()
.fill(.black.opacity(0.6))
.frame(width: 50, height: 50)
.overlay {
Image(systemName: "play.fill")
.font(.title2)
.foregroundColor(.white)
.offset(x: 2) // Visual centering
}
}
.cornerRadius(12)
// Content
VStack(alignment: .leading, spacing: 8) {
Text(sermon.title)
.font(.system(size: 16, weight: .semibold))
.lineLimit(2)
.multilineTextAlignment(.leading)
HStack {
if !sermon.speaker.isEmpty {
Text(sermon.speaker)
.font(.system(size: 14))
.foregroundColor(.secondary)
.lineLimit(1)
}
Spacer()
if let duration = sermon.durationFormatted {
Text(duration)
.font(.system(size: 14))
.foregroundColor(.secondary)
}
}
Text(sermon.formattedDate)
.font(.system(size: 13))
.foregroundColor(.secondary)
}
.padding(.horizontal, 4)
}
.frame(height: 300)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16))
.shadow(color: .black.opacity(0.05), radius: 8, x: 0, y: 2)
}
}
struct ChurchEventHighlightCard: View {
let event: ChurchEvent
var body: some View {
HStack(spacing: 16) {
// Date indicator
VStack(spacing: 4) {
Text(event.dayOfMonth)
.font(.system(size: 24, weight: .bold))
.foregroundColor(Color(hex: getBrandColor()))
Text(event.monthAbbreviation)
.font(.system(size: 12, weight: .semibold))
.foregroundColor(.secondary)
.textCase(.uppercase)
}
.frame(width: 50)
.padding(.vertical, 12)
.background(Color(hex: getBrandColor()).opacity(0.1), in: RoundedRectangle(cornerRadius: 12))
VStack(alignment: .leading, spacing: 8) {
Text(event.title)
.font(.system(size: 18, weight: .semibold))
.lineLimit(2)
if !event.description.isEmpty {
Text(event.description.stripHtml())
.font(.system(size: 14))
.foregroundColor(.secondary)
.lineLimit(2)
}
HStack(spacing: 8) {
Image(systemName: "clock.fill")
.font(.caption)
.foregroundColor(Color(hex: getBrandColor()))
Text(event.timeString)
.font(.system(size: 13, weight: .medium))
.foregroundColor(.secondary)
if !event.location.isEmpty {
Image(systemName: "location.fill")
.font(.caption)
.foregroundColor(Color(hex: getBrandColor()))
Text(event.location)
.font(.system(size: 13, weight: .medium))
.foregroundColor(.secondary)
.lineLimit(1)
}
}
}
Spacer()
}
.padding(20)
.frame(height: 140)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16))
.shadow(color: .black.opacity(0.05), radius: 6, x: 0, y: 2)
}
}
// HomeQuickActionCard removed - no longer needed
// MARK: - Extensions
// All date/time formatting now handled by Rust church-core crate (RTSDA Architecture Rules compliance)
// ChurchEvent now includes dayOfMonth, monthAbbreviation, and timeString fields directly from Rust
extension String {
func stripHtml() -> String {
return self.replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression, range: nil)
}
}

View file

@ -1,39 +0,0 @@
import SwiftUI
import AVKit
class PlayerViewController: AVPlayerViewController {
override func viewDidLoad() {
super.viewDidLoad()
allowsPictureInPicturePlayback = true
}
}
struct JellyfinPlayerView: View {
let videoUrl: String
@Environment(\.dismiss) private var dismiss
var body: some View {
if let url = URL(string: videoUrl) {
VideoViewControllerRepresentable(url: url, dismiss: dismiss)
.ignoresSafeArea()
.onAppear {
try? AVAudioSession.sharedInstance().setCategory(.playback)
try? AVAudioSession.sharedInstance().setActive(true)
}
}
}
}
struct VideoViewControllerRepresentable: UIViewControllerRepresentable {
let url: URL
let dismiss: DismissAction
func makeUIViewController(context: Context) -> PlayerViewController {
let controller = PlayerViewController()
controller.player = AVPlayer(url: url)
controller.player?.play()
return controller
}
func updateUIViewController(_ uiViewController: PlayerViewController, context: Context) {}
}

View file

@ -1,57 +0,0 @@
import SwiftUI
struct LivestreamCard: View {
let livestream: Message
var body: some View {
NavigationLink {
if let url = URL(string: livestream.videoUrl) {
VideoPlayerView(url: url)
}
} label: {
VStack(alignment: .leading, spacing: 8) {
// Thumbnail
AsyncImage(url: URL(string: livestream.thumbnailUrl ?? "")) { image in
image.resizable()
} placeholder: {
Color.gray.opacity(0.3)
}
.aspectRatio(16/9, contentMode: .fill)
.clipped()
// Content
VStack(alignment: .leading, spacing: 6) {
Text(livestream.title)
.font(.custom("Montserrat-SemiBold", size: 18))
.lineLimit(2)
HStack {
Text(livestream.speaker)
.font(.custom("Montserrat-Regular", size: 14))
.foregroundColor(.secondary)
Spacer()
Text(livestream.formattedDate)
.font(.custom("Montserrat-Regular", size: 14))
.foregroundColor(.secondary)
}
if livestream.isLiveStream {
Text("LIVE")
.font(.custom("Montserrat-Bold", size: 12))
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.red)
.foregroundColor(.white)
.cornerRadius(4)
}
}
.padding(.horizontal)
.padding(.bottom)
}
.background(Color(.systemBackground))
.cornerRadius(8)
}
}
}

274
Views/MainAppView.swift Normal file
View file

@ -0,0 +1,274 @@
import SwiftUI
import MapKit
enum SidebarSelection: Hashable {
case home
case events
case watch
case connect
case bulletins
}
struct MainAppView: View {
@State private var dataService = ChurchDataService.shared
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@State private var selectedView: SidebarSelection = .home
var body: some View {
Group {
if horizontalSizeClass == .regular {
// iPad: Use standard NavigationSplitView
NavigationSplitView {
SidebarView(selectedView: $selectedView)
} detail: {
NavigationStack {
selectedDetailView
.environment(dataService)
}
}
} else {
// iPhone: Tab navigation
TabView {
NavigationStack {
HomeFeedView()
.environment(dataService)
}
.tabItem {
Label("Home", systemImage: "house.fill")
}
NavigationStack {
EventsListView()
.environment(dataService)
}
.tabItem {
Label("Events", systemImage: "calendar")
}
NavigationStack {
WatchView()
.environment(dataService)
}
.tabItem {
Label("Watch", systemImage: "play.rectangle.fill")
}
NavigationStack {
ConnectView()
.environment(dataService)
}
.tabItem {
Label("Connect", systemImage: "person.2.fill")
}
NavigationStack {
BulletinsView()
.environment(dataService)
}
.tabItem {
Label("Bulletins", systemImage: "newspaper.fill")
}
}
.tint(Color(hex: "fb8b23"))
}
}
.task {
await dataService.loadHomeFeed()
}
.overlay {
SharedVideoOverlay()
}
}
@ViewBuilder
private var selectedDetailView: some View {
switch selectedView {
case .home:
HomeFeedView()
case .events:
EventsListView() // Just use the regular list view - NavigationStack will handle details
case .watch:
WatchView()
case .connect:
ConnectView()
case .bulletins:
BulletinsView()
}
}
}
struct SidebarView: View {
@Binding var selectedView: SidebarSelection
var body: some View {
List {
Button(action: { selectedView = .home }) {
Label("Home", systemImage: "house.fill")
}
.foregroundColor(selectedView == .home ? .blue : .primary)
Button(action: { selectedView = .events }) {
Label("Events", systemImage: "calendar")
}
.foregroundColor(selectedView == .events ? .blue : .primary)
Button(action: { selectedView = .watch }) {
Label("Watch", systemImage: "play.rectangle.fill")
}
.foregroundColor(selectedView == .watch ? .blue : .primary)
Button(action: { selectedView = .connect }) {
Label("Connect", systemImage: "person.2.fill")
}
.foregroundColor(selectedView == .connect ? .blue : .primary)
Button(action: { selectedView = .bulletins }) {
Label("Bulletins", systemImage: "newspaper.fill")
}
.foregroundColor(selectedView == .bulletins ? .blue : .primary)
// Quick Actions removed - functionality available via main tabs
}
.navigationTitle("RTSDA")
.navigationBarTitleDisplayMode(.large)
}
}
// MARK: - Placeholder Views (to be implemented)
// MARK: - New iPad Cards
struct LiveStreamCard: View {
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
var body: some View {
Button {
// Use shared video player like sermons do
if let livestreamUrl = getLivestreamUrl() {
SharedVideoManager.shared.playVideo(url: livestreamUrl)
}
} label: {
VStack(spacing: 0) {
ZStack {
// Background gradient
LinearGradient(
colors: [Color.red, Color.red.opacity(0.8)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.frame(height: horizontalSizeClass == .regular ? 200 : 150)
// Live indicator and play button
VStack(spacing: 16) {
HStack {
// LIVE indicator
HStack(spacing: 6) {
Circle()
.fill(.white)
.frame(width: 8, height: 8)
.opacity(0.9)
Text("LIVE")
.font(.system(size: 14, weight: .bold))
.foregroundColor(.white)
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.black.opacity(0.6))
.cornerRadius(20)
Spacer()
}
// Play button
Circle()
.fill(.white.opacity(0.9))
.frame(width: horizontalSizeClass == .regular ? 70 : 60,
height: horizontalSizeClass == .regular ? 70 : 60)
.overlay {
Image(systemName: "play.fill")
.font(.system(size: horizontalSizeClass == .regular ? 28 : 24))
.foregroundColor(.red)
.offset(x: 2) // Visual centering
}
.shadow(color: .black.opacity(0.3), radius: 4)
}
.padding(20)
}
// Content section
VStack(alignment: .leading, spacing: 12) {
HStack {
VStack(alignment: .leading, spacing: 6) {
Text("Live Stream")
.font(.system(size: horizontalSizeClass == .regular ? 20 : 18, weight: .bold))
.foregroundColor(.primary)
Text("Join our live worship service")
.font(.system(size: horizontalSizeClass == .regular ? 16 : 14))
.foregroundColor(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.title2)
.foregroundColor(.secondary)
}
}
.padding(20)
}
}
.buttonStyle(PlainButtonStyle())
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16))
.shadow(color: .black.opacity(0.1), radius: 8, x: 0, y: 4)
}
private func getLivestreamUrl() -> URL? {
// Use Rust function for JSON parsing - NO business logic in Swift!
let statusJson = fetchStreamStatusJson()
let streamUrl = extractStreamUrlFromStatus(statusJson: statusJson)
return streamUrl.isEmpty ? nil : URL(string: streamUrl)
}
}

View file

@ -1,60 +0,0 @@
import SwiftUI
import AVKit
struct MessageCard: View {
let message: Message
var body: some View {
NavigationLink {
if let url = URL(string: message.videoUrl) {
VideoPlayerView(url: url)
}
} label: {
VStack(alignment: .leading, spacing: 8) {
// Thumbnail
AsyncImage(url: URL(string: message.thumbnailUrl ?? "")) { image in
image
.resizable()
.aspectRatio(16/9, contentMode: .fill)
.clipped()
} placeholder: {
Rectangle()
.fill(Color.gray.opacity(0.2))
.aspectRatio(16/9, contentMode: .fill)
}
VStack(alignment: .leading, spacing: 6) {
Text(message.title)
.font(.custom("Montserrat-SemiBold", size: 18))
.lineLimit(2)
HStack {
Text(message.speaker)
.font(.custom("Montserrat-Regular", size: 14))
.foregroundColor(.secondary)
Spacer()
Text(message.formattedDate)
.font(.custom("Montserrat-Regular", size: 14))
.foregroundColor(.secondary)
}
if message.isLiveStream {
Text("LIVE")
.font(.custom("Montserrat-Bold", size: 12))
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.red)
.foregroundColor(.white)
.cornerRadius(4)
}
}
.padding()
}
.background(Color(.systemBackground))
.cornerRadius(12)
.shadow(radius: 2)
}
}
}

View file

@ -1,304 +0,0 @@
import SwiftUI
import AVKit
struct MessagesView: View {
@StateObject private var viewModel = MessagesViewModel()
@State private var selectedYear: String?
@State private var selectedMonth: String?
@State private var showingFilters = false
var body: some View {
ScrollView {
VStack(spacing: 16) {
// Header with Filter Button and Active Filters
VStack(spacing: 8) {
HStack {
Button {
showingFilters = true
} label: {
HStack {
Image(systemName: "line.3.horizontal.decrease.circle.fill")
Text("Filter")
}
.font(.headline)
.foregroundStyle(.blue)
}
Spacer()
if selectedYear != nil || selectedMonth != nil {
Button(action: {
selectedYear = nil
selectedMonth = nil
viewModel.filterContent(year: nil, month: nil)
}) {
Text("Clear Filters")
.foregroundStyle(.red)
.font(.subheadline)
}
}
}
// Active Filters Display
if selectedYear != nil || selectedMonth != nil || viewModel.currentMediaType == .livestreams {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
// Media Type Pill
Text(viewModel.currentMediaType == .sermons ? "Sermons" : "Live Archives")
.font(.footnote.weight(.medium))
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color.blue.opacity(0.1))
.foregroundStyle(.blue)
.clipShape(Capsule())
// Year Pill (if selected)
if let year = selectedYear {
Text(year)
.font(.footnote.weight(.medium))
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color.blue.opacity(0.1))
.foregroundStyle(.blue)
.clipShape(Capsule())
}
// Month Pill (if selected)
if let month = selectedMonth {
Text(formatMonth(month))
.font(.footnote.weight(.medium))
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color.blue.opacity(0.1))
.foregroundStyle(.blue)
.clipShape(Capsule())
}
}
.padding(.horizontal)
}
}
}
.padding(.horizontal)
// Content Section
if viewModel.isLoading {
ProgressView()
.padding()
} else if viewModel.error != nil {
VStack {
Text("Error loading content")
.foregroundColor(.red)
Button("Try Again") {
Task {
await viewModel.refreshContent()
}
}
}
.padding()
} else if viewModel.filteredMessages.isEmpty {
Text("No messages available")
.padding()
} else {
LazyVStack(spacing: 16) {
if let livestream = viewModel.livestream {
MessageCard(message: livestream)
.padding(.horizontal)
}
ForEach(viewModel.filteredMessages) { message in
MessageCard(message: message)
.padding(.horizontal)
}
}
}
}
}
.navigationTitle(viewModel.currentMediaType == .sermons ? "Sermons" : "Live Archives")
.refreshable {
await viewModel.refreshContent()
}
.sheet(isPresented: $showingFilters) {
NavigationStack {
FilterView(
currentMediaType: $viewModel.currentMediaType,
selectedYear: $selectedYear,
selectedMonth: $selectedMonth,
availableYears: viewModel.availableYears,
availableMonths: viewModel.availableMonths,
onMediaTypeChange: { newType in
Task {
await viewModel.loadContent(mediaType: newType)
}
},
onYearChange: { year in
if let year = year {
viewModel.updateMonthsForYear(year)
}
viewModel.filterContent(year: year, month: selectedMonth)
},
onMonthChange: { month in
viewModel.filterContent(year: selectedYear, month: month)
}
)
.navigationTitle("Filter Content")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Done") {
showingFilters = false
}
}
ToolbarItem(placement: .topBarTrailing) {
if selectedYear != nil || selectedMonth != nil {
Button("Reset") {
selectedYear = nil
selectedMonth = nil
viewModel.filterContent(year: nil, month: nil)
showingFilters = false
}
.foregroundStyle(.red)
}
}
}
}
.presentationDetents([.medium])
}
}
private func formatMonth(_ month: String) -> String {
let formatter = DateFormatter()
formatter.dateFormat = "MM"
if let date = formatter.date(from: month) {
formatter.dateFormat = "MMM"
return formatter.string(from: date)
}
return month
}
}
struct FilterView: View {
@Binding var currentMediaType: JellyfinService.MediaType
@Binding var selectedYear: String?
@Binding var selectedMonth: String?
let availableYears: [String]
let availableMonths: [String]
let onMediaTypeChange: (JellyfinService.MediaType) -> Void
let onYearChange: (String?) -> Void
let onMonthChange: (String?) -> Void
var body: some View {
Form {
Section("Content Type") {
Picker("Type", selection: $currentMediaType) {
Text("Sermons").tag(JellyfinService.MediaType.sermons)
Text("Live Archives").tag(JellyfinService.MediaType.livestreams)
}
.pickerStyle(.segmented)
.onChange(of: currentMediaType) { oldValue, newValue in
selectedYear = nil
selectedMonth = nil
onMediaTypeChange(newValue)
}
}
Section("Year") {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) {
MessageFilterChip(
title: "All",
isSelected: selectedYear == nil,
action: {
selectedYear = nil
selectedMonth = nil
onYearChange(nil)
}
)
ForEach(availableYears, id: \.self) { year in
MessageFilterChip(
title: year,
isSelected: selectedYear == year,
action: {
selectedYear = year
selectedMonth = nil
onYearChange(year)
}
)
}
}
.padding(.horizontal, 4)
}
}
if selectedYear != nil && !availableMonths.isEmpty {
Section("Month") {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) {
MessageFilterChip(
title: "All",
isSelected: selectedMonth == nil,
action: {
selectedMonth = nil
onMonthChange(nil)
}
)
ForEach(availableMonths, id: \.self) { month in
MessageFilterChip(
title: formatMonth(month),
isSelected: selectedMonth == month,
action: {
selectedMonth = month
onMonthChange(month)
}
)
}
}
.padding(.horizontal, 4)
}
}
}
}
}
private func formatMonth(_ month: String) -> String {
let formatter = DateFormatter()
formatter.dateFormat = "MM"
if let date = formatter.date(from: month) {
formatter.dateFormat = "MMM"
return formatter.string(from: date)
}
return month
}
}
struct MessageFilterChip: View {
let title: String
let isSelected: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
Text(title)
.font(.subheadline.weight(.medium))
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(isSelected ? Color.blue : Color.gray.opacity(0.15))
.foregroundStyle(isSelected ? .white : .primary)
.clipShape(Capsule())
}
}
}
// Helper extension for optional binding in Picker
extension Binding where Value == String? {
func toUnwrapped(defaultValue: String) -> Binding<String> {
Binding<String>(
get: { self.wrappedValue ?? defaultValue },
set: { self.wrappedValue = $0 }
)
}
}

View file

@ -1,26 +0,0 @@
import SwiftUI
struct OwncastView: View {
@StateObject private var viewModel = OwncastViewModel()
var body: some View {
NavigationStack {
Group {
if let streamUrl = viewModel.streamUrl {
VideoPlayerView(url: streamUrl)
} else {
ContentUnavailableView {
Label("Stream Offline", systemImage: "video.slash")
} description: {
Text("The live stream is currently offline")
}
}
}
.navigationTitle("Live Stream")
.navigationBarTitleDisplayMode(.inline)
}
.task {
await viewModel.checkStreamStatus()
}
}
}

View file

@ -0,0 +1,143 @@
import SwiftUI
import AVKit
@preconcurrency import MediaPlayer
// Global video player state
class SharedVideoManager: ObservableObject {
static let shared = SharedVideoManager()
@Published var currentVideoURL: URL?
@Published var showFullScreenPlayer = false
@Published var isInPiPMode = false
// Track the current player to force PiP stop
weak var currentPlayerController: AVPlayerViewController?
// Store current media info for lock screen controls
var currentTitle: String?
var currentArtworkURL: String?
private init() {
}
func playVideo(url: URL, title: String? = nil, artworkURL: String? = nil) {
// Store media info for lock screen controls
currentTitle = title
currentArtworkURL = artworkURL
// If we already have a video playing, we need to switch to the new one
if currentVideoURL != nil {
if isInPiPMode {
// Force stop PiP if it's active
if let playerController = currentPlayerController {
// Force stop the player to close PiP
playerController.player?.pause()
playerController.player = nil
}
}
// Clear the old video first to ensure proper cleanup
currentVideoURL = nil
showFullScreenPlayer = false
isInPiPMode = false
currentPlayerController = nil
// Small delay to let old player fully cleanup before starting new one
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
self.currentVideoURL = url
self.showFullScreenPlayer = true
self.isInPiPMode = false
}
} else {
// No existing video, start immediately
currentVideoURL = url
showFullScreenPlayer = true
isInPiPMode = false
}
}
func hideFullScreenPlayer() {
showFullScreenPlayer = false
}
func stopVideo() {
// Stop the actual player first
if let playerController = currentPlayerController {
playerController.player?.pause()
playerController.player = nil
}
// Clear all state
currentVideoURL = nil
showFullScreenPlayer = false
isInPiPMode = false
currentPlayerController = nil
currentTitle = nil
currentArtworkURL = nil
}
func setPiPMode(_ isPiP: Bool) {
isInPiPMode = isPiP
if isPiP {
// When PiP starts, hide full screen but DON'T touch currentVideoURL
showFullScreenPlayer = false
} else {
// When PiP ends, show full screen again
showFullScreenPlayer = true
}
}
}
// Shared video overlay that exists at app level - GLOBAL PERSISTENT PLAYER
struct SharedVideoOverlay: View {
@StateObject private var videoManager = SharedVideoManager.shared
@State private var currentPlayerURL: URL?
var body: some View {
ZStack {
// ALWAYS render the persistent player when we have a URL
// Force recreate when URL changes to ensure old player is destroyed
if let url = videoManager.currentVideoURL {
PersistentVideoPlayer(url: url)
.id(url.absoluteString) // Force recreate when URL changes
.opacity(videoManager.showFullScreenPlayer ? 1 : 0) // Show/hide but never destroy
.allowsHitTesting(videoManager.showFullScreenPlayer) // Only interactive when visible
.background(videoManager.showFullScreenPlayer ? Color.black : Color.clear)
.ignoresSafeArea()
.onAppear {
}
}
}
.onChange(of: videoManager.showFullScreenPlayer) { _, isVisible in
}
.onChange(of: videoManager.currentVideoURL) { oldURL, newURL in
if let old = oldURL, let new = newURL, old != new {
} else if newURL == nil {
}
}
}
}
// Persistent video player that stays alive
struct PersistentVideoPlayer: View {
let url: URL
var body: some View {
VideoPlayerView(url: url)
.onAppear {
}
.onDisappear {
}
}
}

View file

@ -1,7 +1,7 @@
import SwiftUI import SwiftUI
struct SplashScreenView: View { struct SplashScreenView: View {
@StateObject private var configService = ConfigService.shared @State private var dataService = ChurchDataService.shared
@State private var isActive = false @State private var isActive = false
@State private var size = 0.8 @State private var size = 0.8
@State private var opacity = 0.5 @State private var opacity = 0.5
@ -23,86 +23,125 @@ struct SplashScreenView: View {
var body: some View { var body: some View {
if isActive { if isActive {
ContentView() MainAppView()
} else { } else {
ZStack { ZStack {
LinearGradient(gradient: Gradient(colors: [ // Modern background matching the app theme
Color(hex: "3b0d11"), LinearGradient(
Color(hex: "21070a") colors: [.blue.opacity(0.1), .indigo.opacity(0.05)],
]), startPoint: .top, endPoint: .bottom) startPoint: .topLeading,
endPoint: .bottomTrailing
)
.ignoresSafeArea() .ignoresSafeArea()
// Background pattern for subtle texture
GeometryReader { geometry in
ForEach(0..<20, id: \.self) { _ in
Circle()
.fill(.white.opacity(0.02))
.frame(width: 20, height: 20)
.position(
x: CGFloat.random(in: 0...geometry.size.width),
y: CGFloat.random(in: 0...geometry.size.height)
)
}
}
VStack(spacing: 20) { VStack(spacing: 20) {
Image("sdalogo") Spacer()
.resizable()
.scaledToFit()
.frame(width: 80, height: 80)
Text("Rockville-Tolland SDA Church") // Logo and church info card
.font(.custom("Montserrat-SemiBold", size: 24)) VStack(spacing: 24) {
.foregroundColor(.white) // Logo with modern background
.multilineTextAlignment(.center) ZStack {
Circle()
.fill(.regularMaterial)
.frame(width: 120, height: 120)
.shadow(color: .black.opacity(0.1), radius: 8, x: 0, y: 4)
Image("sdalogo")
.resizable()
.scaledToFit()
.frame(width: 80, height: 80)
}
VStack(spacing: 12) {
Text("Rockville-Tolland")
.font(.custom("Montserrat-SemiBold", size: 28))
.foregroundStyle(.primary)
Text("SDA Church")
.font(.custom("Montserrat-Regular", size: 20))
.foregroundStyle(.secondary)
// Modern separator
Capsule()
.fill(Color(hex: "fb8b23"))
.frame(width: 80, height: 3)
}
}
Rectangle() Spacer()
.fill(Color(hex: "fb8b23")) .frame(maxHeight: 40)
.frame(width: 60, height: 2)
Text(bibleVerse) // Bible verse card
.font(.custom("Lora-Italic", size: 18)) if !bibleVerse.isEmpty {
.foregroundColor(.white.opacity(0.8)) VStack(spacing: 16) {
.multilineTextAlignment(.center) // Quote icon
Image(systemName: "quote.opening")
.font(.title2)
.foregroundStyle(Color(hex: "fb8b23").opacity(0.8))
VStack(spacing: 12) {
Text(bibleVerse)
.font(.custom("Lora-Italic", size: 18))
.foregroundStyle(.primary)
.multilineTextAlignment(.center)
.lineSpacing(6)
Text(bibleReference)
.font(.custom("Montserrat-Regular", size: 14))
.foregroundStyle(Color(hex: "fb8b23"))
.fontWeight(.medium)
}
}
.padding(24)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 20))
.shadow(color: .black.opacity(0.1), radius: 12, x: 0, y: 6)
.padding(.horizontal, 20) .padding(.horizontal, 20)
.lineSpacing(4) }
Text(bibleReference) Spacer()
.font(.custom("Montserrat-Regular", size: 14))
.foregroundColor(Color(hex: "fb8b23"))
} }
.padding() .padding()
.scaleEffect(size) .scaleEffect(size)
.opacity(opacity) .opacity(opacity)
.task { .task {
// First load config // Load a random Bible verse from the Rust crate
await configService.loadConfig() await dataService.loadDailyVerse()
do { if let verse = dataService.dailyVerse {
let verse = try await BibleService.shared.getRandomVerse() bibleVerse = verse.text
bibleVerse = verse.verse
bibleReference = verse.reference bibleReference = verse.reference
} else {
// Calculate display duration based on verse length
let displayDuration = calculateDisplayDuration(for: verse.verse)
// Start fade in animation after verse is loaded
withAnimation(.easeIn(duration: fadeInDuration)) {
self.size = 0.9
self.opacity = 1.0
}
// Wait for fade in + calculated display duration before transitioning
DispatchQueue.main.asyncAfter(deadline: .now() + fadeInDuration + displayDuration) {
withAnimation {
self.isActive = true
}
}
} catch {
// Fallback to a default verse if API fails // Fallback to a default verse if API fails
bibleVerse = "For God so loved the world that he gave his one and only Son, that whoever believes in him shall not perish but have eternal life." bibleVerse = "For God so loved the world that he gave his one and only Son, that whoever believes in him shall not perish but have eternal life."
bibleReference = "John 3:16" bibleReference = "John 3:16"
}
// Calculate duration for fallback verse
let displayDuration = calculateDisplayDuration(for: bibleVerse) // Calculate display duration based on verse length
let displayDuration = calculateDisplayDuration(for: bibleVerse)
// Use same timing for fallback verse
withAnimation(.easeIn(duration: fadeInDuration)) { // Start fade in animation after verse is loaded
self.size = 0.9 withAnimation(.easeIn(duration: fadeInDuration)) {
self.opacity = 1.0 self.size = 0.9
} self.opacity = 1.0
}
DispatchQueue.main.asyncAfter(deadline: .now() + fadeInDuration + displayDuration) {
withAnimation { // Wait for fade in + calculated display duration before transitioning
self.isActive = true DispatchQueue.main.asyncAfter(deadline: .now() + fadeInDuration + displayDuration) {
} withAnimation {
self.isActive = true
} }
} }
} }
@ -111,32 +150,7 @@ struct SplashScreenView: View {
} }
} }
extension Color { // Color extension is already defined in FeedItemCard.swift
init(hex: String) {
let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
var int: UInt64 = 0
Scanner(string: hex).scanHexInt64(&int)
let a, r, g, b: UInt64
switch hex.count {
case 3: // RGB (12-bit)
(a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17)
case 6: // RGB (24-bit)
(a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
case 8: // ARGB (32-bit)
(a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
default:
(a, r, g, b) = (1, 1, 1, 0)
}
self.init(
.sRGB,
red: Double(r) / 255,
green: Double(g) / 255,
blue: Double(b) / 255,
opacity: Double(a) / 255
)
}
}
#Preview { #Preview {
SplashScreenView() SplashScreenView()

View file

@ -1,15 +1,17 @@
import SwiftUI import SwiftUI
import AVKit import AVKit
@preconcurrency import MediaPlayer
struct VideoPlayerView: View { struct VideoPlayerView: View {
let url: URL let url: URL
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@State private var isInPiPMode = false @State private var isInPiPMode = false
@State private var isLoading = true @State private var isLoading = true
@State private var isInNativeFullscreen = false
var body: some View { var body: some View {
ZStack { ZStack {
VideoPlayerViewController(url: url, isInPiPMode: $isInPiPMode, isLoading: $isLoading) VideoPlayerViewController(url: url, isInPiPMode: $isInPiPMode, isLoading: $isLoading, isInNativeFullscreen: $isInNativeFullscreen)
.ignoresSafeArea() .ignoresSafeArea()
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.navigationBarBackButtonHidden(isInPiPMode) .navigationBarBackButtonHidden(isInPiPMode)
@ -20,15 +22,55 @@ struct VideoPlayerView: View {
.scaleEffect(1.5) .scaleEffect(1.5)
.tint(.white) .tint(.white)
} }
// Close button - only show when NOT in native fullscreen mode
#if os(iOS)
if !isInNativeFullscreen {
VStack {
HStack {
Spacer()
Button(action: {
SharedVideoManager.shared.stopVideo()
}) {
ZStack {
// Background circle for better tap target
Circle()
.fill(Color.black.opacity(0.6))
.frame(width: 44, height: 44)
Image(systemName: "xmark.circle.fill")
.font(.system(size: UIDevice.current.userInterfaceIdiom == .pad ? 28 : 20))
.foregroundColor(.white)
}
}
.buttonStyle(.plain)
.frame(width: 44, height: 44)
.contentShape(Circle()) // Make the entire circle tappable
.padding(.top, UIDevice.current.userInterfaceIdiom == .pad ? 20 : 130) // Moved further below volume controls
.padding(.trailing, UIDevice.current.userInterfaceIdiom == .pad ? 20 : 16) // Better aligned with volume button on iPhone
}
Spacer()
}
}
#endif
} }
.onAppear { .onAppear {
print("🎬 DEBUG: VideoPlayerView onAppear")
setupAudio() setupAudio()
} }
.onDisappear {
print("🎬 DEBUG: VideoPlayerView onDisappear")
}
} }
private func setupAudio() { private func setupAudio() {
try? AVAudioSession.sharedInstance().setCategory(.playback) do {
try? AVAudioSession.sharedInstance().setActive(true) try AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback)
try AVAudioSession.sharedInstance().setActive(true)
print("🎬 DEBUG: Audio session configured for playback with movie mode")
} catch {
print("🎬 DEBUG: Failed to configure audio session: \(error)")
}
} }
} }
@ -36,78 +78,221 @@ struct VideoPlayerViewController: UIViewControllerRepresentable {
let url: URL let url: URL
@Binding var isInPiPMode: Bool @Binding var isInPiPMode: Bool
@Binding var isLoading: Bool @Binding var isLoading: Bool
@Binding var isInNativeFullscreen: Bool
func makeUIViewController(context: Context) -> AVPlayerViewController { func makeUIViewController(context: Context) -> AVPlayerViewController {
let player = AVPlayer(url: url) print("🎬 DEBUG: makeUIViewController called with URL: \(url)")
let playerItem = AVPlayerItem(url: url)
let player = AVPlayer(playerItem: playerItem)
// Set metadata directly on the player item
setMetadataOnPlayerItem(playerItem)
let controller = AVPlayerViewController() let controller = AVPlayerViewController()
controller.player = player controller.player = player
controller.allowsPictureInPicturePlayback = true controller.allowsPictureInPicturePlayback = true
controller.delegate = context.coordinator controller.delegate = context.coordinator
// Add observer for buffering state // Disable fullscreen functionality
player.addObserver(context.coordinator, forKeyPath: "timeControlStatus", options: [.old, .new], context: nil) controller.entersFullScreenWhenPlaybackBegins = false
controller.exitsFullScreenWhenPlaybackEnds = false
print("🎬 DEBUG: Setting up player and controller")
context.coordinator.setPlayerController(controller) context.coordinator.setPlayerController(controller)
// Add observers for buffering state and duration
player.addObserver(context.coordinator, forKeyPath: "timeControlStatus", options: [.old, .new], context: nil)
player.currentItem?.addObserver(context.coordinator, forKeyPath: "duration", options: [.old, .new], context: nil)
context.coordinator.hasObserver = true
print("🎬 DEBUG: Observer added successfully")
print("🎬 DEBUG: Starting playback")
player.play() player.play()
return controller return controller
} }
func updateUIViewController(_ uiViewController: AVPlayerViewController, context: Context) {} func updateUIViewController(_ uiViewController: AVPlayerViewController, context: Context) {}
func makeCoordinator() -> Coordinator { func makeCoordinator() -> Coordinator {
Coordinator(isInPiPMode: $isInPiPMode, isLoading: $isLoading) Coordinator(isInPiPMode: $isInPiPMode, isLoading: $isLoading, isInNativeFullscreen: $isInNativeFullscreen)
} }
private func setMetadataOnPlayerItem(_ playerItem: AVPlayerItem) {
let videoManager = SharedVideoManager.shared
var metadata: [AVMetadataItem] = []
// Title
if let title = videoManager.currentTitle {
let titleItem = AVMutableMetadataItem()
titleItem.identifier = .commonIdentifierTitle
titleItem.value = title as NSString
titleItem.extendedLanguageTag = "und"
metadata.append(titleItem)
}
// Artist
let artistItem = AVMutableMetadataItem()
artistItem.identifier = .commonIdentifierArtist
artistItem.value = "Rockville Tolland SDA Church" as NSString
artistItem.extendedLanguageTag = "und"
metadata.append(artistItem)
// Artwork
if let artworkURLString = videoManager.currentArtworkURL,
let artworkURL = URL(string: artworkURLString) {
Task {
do {
let (data, _) = try await URLSession.shared.data(from: artworkURL)
if UIImage(data: data) != nil {
let artworkItem = AVMutableMetadataItem()
artworkItem.identifier = .commonIdentifierArtwork
artworkItem.value = data as NSData
artworkItem.dataType = kCMMetadataBaseDataType_JPEG as String
artworkItem.extendedLanguageTag = "und"
await MainActor.run {
var updatedMetadata = playerItem.externalMetadata
updatedMetadata.append(artworkItem)
playerItem.externalMetadata = updatedMetadata
}
}
} catch {
print("Failed to load artwork for metadata: \(error)")
}
}
}
playerItem.externalMetadata = metadata
}
class Coordinator: NSObject, AVPlayerViewControllerDelegate { class Coordinator: NSObject, AVPlayerViewControllerDelegate {
@Binding var isInPiPMode: Bool @Binding var isInPiPMode: Bool
@Binding var isLoading: Bool @Binding var isLoading: Bool
@Binding var isInNativeFullscreen: Bool
internal var playerController: AVPlayerViewController? internal var playerController: AVPlayerViewController?
private var wasPlayingBeforeDismiss = false private var wasPlayingBeforeDismiss = false
internal var hasObserver = false
init(isInPiPMode: Binding<Bool>, isLoading: Binding<Bool>) { init(isInPiPMode: Binding<Bool>, isLoading: Binding<Bool>, isInNativeFullscreen: Binding<Bool>) {
_isInPiPMode = isInPiPMode _isInPiPMode = isInPiPMode
_isLoading = isLoading _isLoading = isLoading
_isInNativeFullscreen = isInNativeFullscreen
print("🎬 DEBUG: Coordinator init")
super.init() super.init()
} }
func setPlayerController(_ controller: AVPlayerViewController) { func setPlayerController(_ controller: AVPlayerViewController) {
print("🎬 DEBUG: setPlayerController called")
playerController = controller playerController = controller
// Register with SharedVideoManager so it can control PiP
SharedVideoManager.shared.currentPlayerController = controller
print("🎬 DEBUG: Player registered with SharedVideoManager")
} }
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) { override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
print("🎬 DEBUG: observeValue called for keyPath: \(keyPath ?? "nil")")
if keyPath == "timeControlStatus", if keyPath == "timeControlStatus",
let player = object as? AVPlayer { let player = object as? AVPlayer {
let status = player.timeControlStatus
print("🎬 DEBUG: timeControlStatus = \(status.rawValue)")
DispatchQueue.main.async { DispatchQueue.main.async {
self.isLoading = player.timeControlStatus == .waitingToPlayAtSpecifiedRate self.isLoading = player.timeControlStatus == .waitingToPlayAtSpecifiedRate
print("🎬 DEBUG: isLoading set to \(self.isLoading)")
}
} else if keyPath == "duration",
let playerItem = object as? AVPlayerItem {
let duration = playerItem.duration
if duration.isValid && !duration.isIndefinite {
let durationSeconds = CMTimeGetSeconds(duration)
print("🎬 DEBUG: Duration loaded: \(durationSeconds) seconds")
} }
} }
} }
func playerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { func playerViewController(_ playerViewController: AVPlayerViewController, willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) {
print("🎬 DEBUG: willBeginFullScreenPresentation")
DispatchQueue.main.async {
self.isInNativeFullscreen = true
}
if let player = playerController?.player { if let player = playerController?.player {
wasPlayingBeforeDismiss = (player.rate > 0) wasPlayingBeforeDismiss = (player.rate > 0)
print("🎬 DEBUG: wasPlayingBeforeDismiss = \(wasPlayingBeforeDismiss)")
} }
} }
func playerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) { func playerViewController(_ playerViewController: AVPlayerViewController, willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) {
print("🎬 DEBUG: willEndFullScreenPresentation - exiting native fullscreen")
DispatchQueue.main.async {
self.isInNativeFullscreen = false
}
if wasPlayingBeforeDismiss, let player = playerController?.player { if wasPlayingBeforeDismiss, let player = playerController?.player {
// Prevent the player from pausing during transition print("🎬 DEBUG: Restoring playback rate to 1.0")
player.rate = 1.0 player.rate = 1.0
} }
// Notify SharedVideoManager that we're exiting fullscreen
DispatchQueue.main.async {
SharedVideoManager.shared.showFullScreenPlayer = false
print("🎬 DEBUG: Set SharedVideoManager.showFullScreenPlayer = false")
}
} }
func playerViewControllerWillStartPictureInPicture(_ playerViewController: AVPlayerViewController) { func playerViewControllerWillStartPictureInPicture(_ playerViewController: AVPlayerViewController) {
isInPiPMode = true print("🎬 DEBUG: PiP WILL START")
DispatchQueue.main.async {
self.isInPiPMode = true
SharedVideoManager.shared.setPiPMode(true)
print("🎬 DEBUG: isInPiPMode set to true")
}
}
func playerViewControllerDidStartPictureInPicture(_ playerViewController: AVPlayerViewController) {
print("🎬 DEBUG: PiP DID START")
}
func playerViewControllerWillStopPictureInPicture(_ playerViewController: AVPlayerViewController) {
print("🎬 DEBUG: PiP WILL STOP")
} }
func playerViewControllerDidStopPictureInPicture(_ playerViewController: AVPlayerViewController) { func playerViewControllerDidStopPictureInPicture(_ playerViewController: AVPlayerViewController) {
isInPiPMode = false print("🎬 DEBUG: PiP DID STOP")
DispatchQueue.main.async {
self.isInPiPMode = false
SharedVideoManager.shared.setPiPMode(false)
print("🎬 DEBUG: isInPiPMode set to false")
}
}
func playerViewController(_ playerViewController: AVPlayerViewController, failedToStartPictureInPictureWithError error: Error) {
print("🎬 DEBUG: PiP FAILED TO START: \(error)")
}
func playerViewController(_ playerViewController: AVPlayerViewController, restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void) {
print("🎬 DEBUG: RESTORE USER INTERFACE FOR PIP STOP - This is the 'Return to App' action")
print("🎬 DEBUG: Current isInPiPMode: \(isInPiPMode)")
print("🎬 DEBUG: playerController exists: \(playerController != nil)")
// This is the critical method for "Return to App"
DispatchQueue.main.async {
print("🎬 DEBUG: Calling completionHandler(true)")
completionHandler(true)
}
} }
deinit { deinit {
if let player = playerController?.player { print("🎬 DEBUG: Coordinator deinit")
if let player = playerController?.player, hasObserver {
print("🎬 DEBUG: Removing observers in deinit")
player.removeObserver(self, forKeyPath: "timeControlStatus") player.removeObserver(self, forKeyPath: "timeControlStatus")
player.currentItem?.removeObserver(self, forKeyPath: "duration")
hasObserver = false
} }
} }
} }
} }

179
Views/WatchView.swift Normal file
View file

@ -0,0 +1,179 @@
import SwiftUI
struct WatchView: View {
@Environment(ChurchDataService.self) private var dataService
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@State private var searchText = ""
@State private var selectedTab: WatchTab = .sermons
private let grayBackgroundColor: Color = {
#if os(iOS)
Color(.systemGray6)
#else
Color.gray.opacity(0.2)
#endif
}()
enum WatchTab: String, CaseIterable {
case sermons = "Sermons"
case liveArchives = "Live Archives"
var icon: String {
switch self {
case .sermons:
return "play.rectangle.fill"
case .liveArchives:
return "dot.radiowaves.left.and.right"
}
}
}
var currentContent: [Sermon] {
switch selectedTab {
case .sermons:
return dataService.sermons
case .liveArchives:
return dataService.livestreamArchives
}
}
var filteredContent: [Sermon] {
let content = currentContent
// Apply search text filter
return SearchUtils.searchSermons(content, searchText: searchText, contentType: selectedTab.rawValue.lowercased())
}
var body: some View {
VStack(spacing: 0) {
// Toggle buttons
HStack(spacing: 12) {
ForEach(WatchTab.allCases, id: \.self) { tab in
Button(action: {
selectedTab = tab
Task {
switch tab {
case .sermons:
await dataService.loadAllSermons()
case .liveArchives:
await dataService.loadAllLivestreamArchives()
}
}
}) {
HStack(spacing: 8) {
Image(systemName: tab.icon)
.font(horizontalSizeClass == .regular ? .body : .subheadline)
Text(tab.rawValue)
.font(.system(size: horizontalSizeClass == .regular ? 18 : 16, weight: .medium))
}
.foregroundColor(selectedTab == tab ? .white : .secondary)
.padding(.horizontal, horizontalSizeClass == .regular ? 32 : 20)
.padding(.vertical, horizontalSizeClass == .regular ? 20 : 14)
.background(
selectedTab == tab ?
Color(hex: getBrandColor()) :
Color.clear,
in: RoundedRectangle(cornerRadius: horizontalSizeClass == .regular ? 12 : 10)
)
.overlay(
RoundedRectangle(cornerRadius: horizontalSizeClass == .regular ? 12 : 10)
.strokeBorder(selectedTab == tab ? Color.clear : .secondary.opacity(0.3), lineWidth: 1)
)
.contentShape(RoundedRectangle(cornerRadius: horizontalSizeClass == .regular ? 12 : 10))
}
.buttonStyle(.plain)
}
Spacer()
}
.padding(.horizontal, 20)
.padding(.top, 16)
.padding(.bottom, 12)
// Search bar
HStack {
Image(systemName: "magnifyingglass")
.foregroundColor(.secondary)
TextField(selectedTab == .sermons ? "Search sermons..." : "Search live archives...", text: $searchText)
.textFieldStyle(PlainTextFieldStyle())
if !searchText.isEmpty {
Button(action: { searchText = "" }) {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.secondary)
}
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(grayBackgroundColor)
.cornerRadius(12)
.padding(.horizontal, 20)
.padding(.bottom, 16)
// Content
if dataService.isLoading {
Spacer()
ProgressView(selectedTab == .sermons ? "Loading sermons..." : "Loading live archives...")
Spacer()
} else if filteredContent.isEmpty {
Spacer()
VStack(spacing: 16) {
Image(systemName: selectedTab == .sermons ? "play.rectangle" : "dot.radiowaves.left.and.right")
.font(.system(size: 50))
.foregroundColor(.secondary)
Text(selectedTab == .sermons ? "No sermons found" : "No live archives available")
.font(.headline)
.foregroundColor(.secondary)
if !searchText.isEmpty {
Text("Try adjusting your search terms")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
}
.padding()
Spacer()
} else {
ScrollView {
LazyVStack(spacing: 16) {
// Live stream card - only show when stream is live
if dataService.isStreamLive {
LiveStreamCard()
.padding(.horizontal, 20)
}
ForEach(filteredContent, id: \.id) { sermon in
SermonCard(sermon: sermon, style: .watch)
}
}
.padding(.horizontal, 20)
.padding(.bottom, 100)
}
.refreshable {
switch selectedTab {
case .sermons:
await dataService.loadAllSermons()
case .liveArchives:
await dataService.loadAllLivestreamArchives()
}
// Always refresh stream status when pulling to refresh
await dataService.loadStreamStatus()
}
}
}
.navigationTitle("Watch")
.task {
// Load both sermons, live archives, and stream status on initial load
await dataService.loadAllSermons()
await dataService.loadAllLivestreamArchives()
await dataService.loadStreamStatus()
}
}
}

1102
church_core.swift Normal file

File diff suppressed because it is too large Load diff

1204
church_coreFFI.h Normal file

File diff suppressed because it is too large Load diff

4
church_coreFFI.modulemap Normal file
View file

@ -0,0 +1,4 @@
module church_coreFFI {
header "church_coreFFI.h"
export *
}

BIN
libchurch_core.a Normal file

Binary file not shown.

BIN
libchurch_core_ios.a Normal file

Binary file not shown.

BIN
libchurch_core_tvos.a Normal file

Binary file not shown.

View file

@ -1,107 +0,0 @@
import Foundation
class PocketBaseService {
static let shared = PocketBaseService()
let baseURL = "https://pocketbase.rockvilletollandsda.church/api/collections"
private init() {}
}
@MainActor
class BibleService {
static let shared = BibleService()
private let pocketBaseService = PocketBaseService.shared
private init() {}
struct Verse: Identifiable, Codable {
let id: String
let reference: String
let text: String
let isActive: Bool
enum CodingKeys: String, CodingKey {
case id
case reference
case text
case isActive = "is_active"
}
}
struct VersesRecord: Codable {
let collectionId: String
let collectionName: String
let created: String
let id: String
let updated: String
let verses: VersesData
struct VersesData: Codable {
let id: String
let verses: [Verse]
}
}
private var cachedVerses: [Verse]?
private func getVerses() async throws -> [Verse] {
print("Fetching verses from PocketBase")
let endpoint = "\(PocketBaseService.shared.baseURL)/bible_verses/records/nkf01o1q3456flr"
guard let url = URL(string: endpoint) else {
print("Invalid URL: \(endpoint)")
throw URLError(.badURL)
}
var request = URLRequest(url: url)
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
print("Making request to: \(endpoint)")
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
print("Invalid response type")
throw URLError(.badServerResponse)
}
print("Response status code: \(httpResponse.statusCode)")
guard httpResponse.statusCode == 200 else {
if let errorString = String(data: data, encoding: .utf8) {
print("Error response from server: \(errorString)")
}
throw URLError(.badServerResponse)
}
let decoder = JSONDecoder()
let versesRecord = try decoder.decode(VersesRecord.self, from: data)
return versesRecord.verses.verses
}
func testAllVerses() async throws {
print("\n=== Testing All Verses ===\n")
let verses = try await getVerses()
for verse in verses {
print("Reference: \(verse.reference)")
print("Text: \(verse.text)")
print("-------------------\n")
}
print("=== Test Complete ===\n")
}
}
print("Starting Bible Verses Test...")
// Create a semaphore to signal when the task is complete
let semaphore = DispatchSemaphore(value: 0)
Task {
do {
try await BibleService.shared.testAllVerses()
} catch {
print("Error testing verses: \(error)")
}
semaphore.signal()
}
// Wait for the task to complete
semaphore.wait()