Initial project import and stabilization baseline

This commit is contained in:
dwindown
2026-03-30 21:28:44 +07:00
commit ad33b01231
186 changed files with 20445 additions and 0 deletions

45
.gitignore vendored Normal file
View File

@@ -0,0 +1,45 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
/coverage/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release

45
.metadata Normal file
View File

@@ -0,0 +1,45 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "db50e20168db8fee486b9abf32fc912de3bc5b6a"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
- platform: android
create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
- platform: ios
create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
- platform: linux
create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
- platform: macos
create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
- platform: web
create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
- platform: windows
create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'

76
PRD.md Normal file
View File

@@ -0,0 +1,76 @@
# Product Requirements Document: Smart Digital Prayer Clock (Jam Shalat Masjid)
## 1. Executive Summary
**Objective**: Develop a world-class, offline-first digital signage application for mosques running continuously (24/7) on Android TV Boxes without internet dependency.
**Key Directives**: Avoid the dated, cluttered spreadsheet look of traditional digital clocks. Implement "The Sacred Horizon" design system, focusing on elegance, high visual impact, wide margins, and asymmetrical editorial layouts.
## 2. Core Architecture & Tech Stack
* **Target Platform**: Android TV Box (Android 13/14, 4GB RAM, 64GB ROM) — 1920x1080 Landscape Display.
* **Framework**: Flutter (Latest Stable).
* **Architecture Pattern**: Clean Architecture / MVVM.
* **State Management**: Riverpod (Recommended for predictable timer-based state transitions).
* **Local Storage**: Isar (High-performance NoSQL for offline caching of monthly APIs and Settings).
* **Background Management**: `wakelock_plus` package must be implemented to prevent screen sleeping. Timer handling must be memory-leak safe.
## 3. Design System Strategy: "The Sacred Horizon"
* **Creative North Star**: "The Celestial Anchor" - Calm, reverence, and absolute clarity.
* **Color Palette**:
* **Background (The Void)**: `#131313`
* **Primary (Living Green)**: `#88d982` (Used for current time status and active prayer highlights)
* **Secondary (Sacred Gold)**: `#e9c349` (Used for alerts, Jumu'ah highlights, running text)
* **Typography**:
* **Plus Jakarta Sans**: Display (`3.5rem - 12rem`), used for the massive clock, prayer titles. Bold and commanding. Letter-spacing tighter (`-0.02em`).
* **Manrope**: Used for functional elements (Sub-timers, info panels, marquee). Clean and readable from 2-3 meters.
* **Surfaces & Depth (The "No-Line" Rule)**:
* Strictly **NO 1px solid borders** between content. Separation is created using tonal layering (e.g. `surface_container_low` `#1c1b1b` over `#131313` background).
* Use frosted glass effects (`backdrop-filter`) for floating overlays. Give active items ambient glows instead of hard drop shadows.
## 4. App State Machine & UI Experiences
The application transitions through a cyclical logic based on `DateTime.now()`.
### State 1: NORMAL / ROTATION
Automatically rotates between Main View and Slideshow based on Admin configurations using smooth `AnimatedSwitcher`.
* **Main Screen**: Asymmetrical layout. High-contrast massive clock in the center/center-left. Hijri & Gregorian dates. `Bottom Area`: 5/6 prayer cards spanning horizontally. The active/upcoming prayer is elevated using `primary_container` color. `Bottom Edge`: Continuous marquee text.
* **Slideshow Screen**: Cinematic full-screen graphic behind a subtle gradient overlay. A floating "Glass" widget anchors to the top-right showing current time and upcoming prayer countdown. Central focus is on the poster/quote text.
### State 2: MENUJU ADZAN
* Triggered ~X minutes before the exact prayer time (configurable per prayer).
* Forced lock to Main Screen (cancels slideshow rotations). Focus shifted to the active countdown. Pulsing UI indicators.
### State 3: ADZAN (Time Reached)
* Full-screen alert takeover. Glowing `Secondary` texts: "WAKTU ADZAN [NAMA SHALAT]". Short audio chime.
### State 4: MENUJU IQOMAH
* Massive, dominant countdown timer taking over the screen center. "PERSIAPAN SHALAT. Luruskan dan rapatkan shaf".
* **Jumu'ah Override**: On Fridays, Dzuhur is replaced with "JUMAT". During the Iqomah countdown phase, the screen transforms into a "Friday Khutbah Information" panel—displaying Imam and Khatib names with an overarching countdown to the Khutbah itself.
### State 5: SHALAT / BLACK SCREEN
* Post-iqomah. Screen turns to absolute minimal `Black` (`#000000`).
* Only low-opacity (10-15%) texts remain: "Shalat Sedang Berlangsung" and "Mohon Nonaktifkan Alat Komunikasi".
### State 6: KEMBALI NORMAL
* System resets to `STATE 1` after the configured blank screen duration ends.
## 5. Offline-First API & Admin Configuration
The App runs 99% offline. Admin dashboard is accessed securely (e.g., hidden D-pad combo).
* **TV-Optimized Admin UX**: Driven purely by D-Pad logic. Focus management is critical (High contrast borders & states for selected inputs). Avoid heavy typing by utilizing sliders, presets, and large selection buttons.
* **Admin Features (Bento Grid Layout)**:
1. **Identity Settings**: Mosque Name, Address.
2. **Sync Data**: The only feature that requires internet (via mobile tethering). Button pulls 1 full month of data from public prayer API based on City ID.
3. **Timing & Durations**:
* Pre-Adzan Lead (minutes per prayer).
* Iqomah Duration (minutes per prayer).
* Blank Screen Duration (minutes, separate for normal days and Friday).
4. **Appearance**: Marquee text configuration, Khatib / Imam input fields for the week.
## 6. Implementation Roadmap
1. **Phase 1: Foundation Setup**: Initialize Flutter project, Riverpod, Isar database, and `wakelock_plus`. Define configuration models.
2. **Phase 2: Data layer & State Machine**: API fetching, offline database seed logic. Build the central `Timer` loop dictating the State Machine.
3. **Phase 3: Design System Tokens**: Translate "The Sacred Horizon" stitch designs into global Flutter themes, custom text styles, and core widgets.
4. **Phase 4: TV UI Development**:
* Build all 5 UI states (Normal/Slideshow, Adzan Alert, Iqomah Countdown, Khutbah Override, Black Screen).
5. **Phase 5: TV Admin Dashboard**: Implement the D-Pad optimized configuration interface ensuring state saves correctly to `Isar`.
6. **Phase 6: Final Polish**: Focus transition animations, memory leak testing (crucial for 24/7 runtimes), and Android APK generation.

119
README.md Normal file
View File

@@ -0,0 +1,119 @@
# JamShalat Masjid Screen
Smart digital prayer clock and mosque signage app built with Flutter for Android TV boxes and landscape displays.
## Purpose
This app is meant to run continuously inside a mosque as a full-screen prayer information display. It combines:
- live clock and daily prayer schedule
- pre-adzan and adzan alert states
- iqomah countdown flow
- Friday (`Jumat`) special screens
- black screen during prayer
- slideshow and branded background support
- local admin panel for changing mosque identity, timing, and appearance settings
The product direction is documented in [PRD.md](/Users/dwindown/CascadeProjects/jamshalat-masjid-screen/PRD.md).
## Current Stack
- Flutter
- Riverpod for app state
- Hive for local persistence
- `http` for remote APIs
- `wakelock_plus` for always-on screen behavior
- `audioplayers` for adzan and iqomah sounds
## Current Architecture
The current implementation is a pragmatic Flutter app centered around:
- `lib/main.dart`: app bootstrap, Hive initialization, fullscreen setup
- `lib/providers.dart`: clock stream, settings state, screen-state machine, slideshow rotation
- `lib/data/local/models.dart`: Hive models and adapters
- `lib/data/services/`: sync, prayer API, background API, sound service
- `lib/features/home/`: public display screens
- `lib/features/admin/`: settings/admin panel
This is not yet a strict Clean Architecture / MVVM implementation from the PRD. It is a working product foundation optimized for iteration speed.
## Implemented Screens
- Main screen
- Slideshow screen
- Adzan alert screen
- Iqomah countdown screen
- Friday preparation screen
- Friday khutbah screen
- Black screen during prayer
- Admin settings screen
## Data Sources
- Prayer times: MyQuran sholat API
- Background images: Unsplash API
The app is designed to behave offline-first after schedule data has been synced and stored locally.
## Assets
Included assets currently used by the app:
- fonts: Plus Jakarta Sans, Manrope
- sounds: `beep.mp3`, `3-detik-countdown.mp3`
- images: app icon and user-configured local slideshow/background images
## Local Development
### Requirements
- Flutter SDK installed and available in `PATH`
- Android toolchain if building APKs
### Run
```bash
flutter pub get
flutter run
```
### Analyze
```bash
flutter analyze
```
### Test
```bash
flutter test
```
## Current Stabilization Status
The app is in a workable development state, but not yet fully stabilized.
Known issues at the time of writing:
- `test/widget_test.dart` is broken placeholder boilerplate and currently fails `flutter test`
- Android manifest does not yet declare `INTERNET`, which will block API-dependent features on device
- there are multiple deprecated `withOpacity` usages reported by `flutter analyze`
- some placeholders are still hardcoded, including Hijri date text in Friday-related screens
- README and repo hygiene were incomplete and are being normalized
## Immediate Priorities
1. Fix test baseline so `flutter test` becomes useful again.
2. Add Android `INTERNET` permission.
3. Remove broken placeholders and obvious UI/data defects.
4. Stabilize schedule sync and runtime state transitions.
5. Continue feature work from the PRD after the baseline is reliable.
## Notes About Git
This workspace currently does not contain a local `.git` directory even though a remote repository exists:
- `https://git.backoffice.biz.id/dwindown/jamshalat-masjid-screen.git`
If needed, the local folder can be attached to that remote in a follow-up step.

883
admin-ui-brief.md Normal file
View File

@@ -0,0 +1,883 @@
# Flutter TV Admin UI: Best Practices & Implementation Guide
## Executive Summary
**Objective**: Build a world-class admin dashboard experience for Android TV using Flutter, maintaining the single-APK architecture while delivering excellent UX despite TV input constraints.
**Key Insight**: Flutter is fully capable of building excellent TV dashboards. The cross-platform nature doesn't limit quality - it means we can build platform-optimized experiences with proper focus management and TV-specific widgets.
**Target Platform**: Android TV Box (1920x1080 landscape, D-pad navigation, no touch input)
---
## 1. Understanding Android TV UX Constraints
### Critical Challenges
```
❌ No touch input (D-pad navigation only)
❌ Awkward text input (on-screen keyboard via remote)
❌ Screen distance (2-3 meters from viewer)
❌ Limited input methods (remote control only)
❌ No mouse hover states
❌ Small text is unreadable
```
### Our Advantages
```
✅ Flutter has excellent focus management built-in
✅ Rich animation and transition support
✅ Custom widgets are straightforward to build
✅ TV-specific packages available
✅ We control the entire experience
✅ Hardware acceleration for smooth animations
✅ Scalable UI via MediaQuery
```
---
## 2. Focus Management (The Foundation)
Focus management is **the most critical** aspect of TV UI. All interactions flow through proper focus handling.
### Core Principles
1. **Always show focus**: Users must see what's currently selected
2. **Predictable navigation**: D-pad moves in expected directions
3. **Large focus targets**: Everything must be easily selectable
4. **Clear visual feedback**: Animated focus indicators
5. **Sound feedback**: Audible confirmation of focus changes
### Implementation Pattern
```dart
// All TV widgets should follow this pattern:
class TVWidget extends StatefulWidget {
@override
State<TVWidget> createState() => _TVWidgetState();
}
class _TVWidgetState extends State<TVWidget> {
late FocusNode _focusNode;
@override
void initState() {
super.initState();
_focusNode = FocusNode();
// Listen to focus changes for visual feedback
_focusNode.addListener(() {
setState(() {
// Update UI when focus changes
});
// Play sound feedback
if (_focusNode.hasFocus) {
TVSoundService().playFocusSound();
}
});
}
@override
void dispose() {
_focusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Focus(
focusNode: _focusNode,
onKeyEvent: (node, event) {
// Handle D-pad navigation
return KeyEventResult.ignored;
},
child: Builder(
builder: (context) {
final isFocused = _focusNode.hasFocus;
// Build focused/unfocused states
},
),
);
}
}
```
---
## 3. TV Scaling System
All dimensions must scale relative to screen width to ensure consistency across different TV sizes.
```dart
class TVScaling {
static double scale(BuildContext context) {
return MediaQuery.of(context).size.width / 1920;
}
// Helper methods for common scaled values
static double padding(BuildContext context) => 32 * scale(context);
static double gap(BuildContext context) => 24 * scale(context);
static double borderRadius(BuildContext context) => 12 * scale(context);
// Typography scale
static double fontSizeHeading(BuildContext context) => 48 * scale(context);
static double fontSizeTitle(BuildContext context) => 36 * scale(context);
static double fontSizeBody(BuildContext context) => 28 * scale(context);
static double fontSizeCaption(BuildContext context) => 22 * scale(context);
}
```
---
## 4. TV-Optimized Form Widgets
### A. TV TextField
**Key Features:**
- Extra large hit targets (minimum 48x48dp)
- Clear focus indication (border + background + shadow)
- Keyboard hint for users
- Auto-scroll into view when focused
- Sound feedback on focus
**Usage:**
```dart
TVTextField(
label: 'Nama Masjid',
value: settings.masjidName,
onChanged: (value) => updateSetting('masjidName', value),
hint: 'Masjid Ar-Rahman',
autofocus: false,
)
```
### B. TV Selection Field
**Key Features:**
- Dropdown/selector pattern (avoids typing)
- Search functionality for long lists
- Clear visual hierarchy
- Keyboard navigation (arrow keys)
- Selected state indication
**Usage:**
```dart
TVSelectionField<String>(
label: 'Pilih Lokasi',
value: settings.cityIdApi,
options: [
TVSelectionOption(
label: 'Kota Yogyakarta',
value: '577ef1154f3240ad5b9b413aa7346a1e',
subtitle: 'Daerah Istimewa Yogyakarta',
icon: Icons.location_city,
),
TVSelectionOption(
label: 'Jakarta Pusat',
value: 'another_id_here',
subtitle: 'DKI Jakarta',
icon: Icons.location_city,
),
],
onChanged: (value) => saveLocation(value),
)
```
### C. TV Button
**Key Features:**
- Large touch targets (minimum 48x48dp)
- Multiple style variants (primary, secondary, danger)
- Icon + label support
- Animated focus feedback
- Sound on press
- Keyboard activation (Enter/Select)
**Usage:**
```dart
TVButton(
label: 'Simpan Pengaturan',
icon: Icons.save,
style: TVButtonStyle.primary,
onPressed: () => saveSettings(),
)
// Icon-only variant
TVButton.icon(
icon: Icons.arrow_back,
label: 'Kembali',
onPressed: () => Navigator.pop(context),
)
```
### D. TV Toggle Switch
**Key Features:**
- Large clickable area (entire row)
- Clear on/off state
- Animated transition
- Focus indication
- Label with description
**Usage:**
```dart
TVSwitch(
label: 'Auto Kalibrasi Online',
subtitle: 'Sinkronisasi waktu otomatis saat online',
value: settings.autoCalibrationEnabled,
onChanged: (value) => updateSetting('autoCalibration', value),
)
```
---
## 5. Layout Patterns
### A. Grid-Based Dashboard
**Purpose**: Main admin navigation screen
**Characteristics:**
- 3-column grid layout
- Large cards with icons
- Clear visual hierarchy
- Easy D-pad navigation
- Focus flows naturally left-to-right, top-to-bottom
**Structure:**
```
┌─────────────┬─────────────┬─────────────┐
│ Location │ Appearance │ Settings │
│ & Schedule│ │ │
├─────────────┼─────────────┼─────────────┤
│ Rotation │ About │ │
└─────────────┴─────────────┴─────────────┘
```
### B. Wizard-Style Multi-Step Forms
**Purpose**: Break complex forms into manageable steps
**Benefits:**
- Reduces cognitive load
- Easier navigation
- Progress indication
- Can save state between steps
- Less overwhelming than long forms
**When to Use:**
- Initial setup wizard
- Complex configuration with multiple sections
- Settings that require multiple steps
- Data entry with validation
**Structure:**
```
Step 1: Identity → Step 2: Location → Step 3: Appearance → Step 4: Confirm
↓ ↓ ↓ ↓
[Masjid Name] [Select City] [Theme Color] [Review All]
[Address] [Sync Data] [Background] [Save & Exit]
```
### C. Tabbed Interface
**Purpose**: Organize related settings
**Benefits:**
- Clear categorization
- Easy to add new sections
- Familiar pattern from web
- Works well with D-pad
**Tab Structure:**
1. **Identitas** - Mosque name, address
2. **Jadwal & Sync** - Location, synchronization
3. **Tampilan** - Theme, background, slideshow
4. **Rotasi** - Screen timing
5. **Pembarisan** - Updates, version
6. **Tentang** - App information
---
## 6. Reducing Text Input (The Secret Sauce)
The best TV admin interfaces **minimize typing**. This is crucial because:
1. TV remotes are terrible for text input
2. On-screen keyboards are slow and error-prone
3. Screen distance makes typing difficult
4. User frustration increases with typing
### Strategies to Minimize Typing
#### A. Use Selection Instead of Text Input
**Bad (Text Input):**
```dart
TextField(
decoration: InputDecoration(labelText: 'City ID API'),
)
```
**Good (Selection):**
```dart
TVSelectionField<String>(
label: 'Pilih Kota',
value: settings.cityIdApi,
options: cityOptions,
onChanged: (value) => saveCity(value),
)
```
#### B. Search + Select Pattern
For long lists (like 500+ Indonesian cities):
```dart
TVSearchSelectField<String>(
label: 'Cari Lokasi',
hint: 'Ketik nama kota...',
searchDelegate: MyQuranCitySearchDelegate(),
onSelect: (city) => saveCity(city),
)
```
#### C. Preset Options with Custom Value
```dart
TVSelectionField<String>(
label: 'Warna Tema',
value: settings.themeColor,
options: [
TVSelectionOption(label: 'Hijau', value: '0xFF006400'),
TVSelectionOption(label: 'Biru', value: '0xFF0000FF'),
TVSelectionOption(label: 'Merah', value: '0xFFFF0000'),
TVSelectionOption(label: 'Emas', value: '0xFFFFD700'),
TVSelectionOption(label: 'Kustom...', value: 'custom'),
],
onChanged: (value) {
if (value == 'custom') {
// Show color picker dialog
} else {
saveThemeColor(value);
}
},
)
```
#### D. Slider for Numeric Values
**Bad (Number Input):**
```dart
TextField(
keyboardType: TextInputType.number,
decoration: InputDecoration(labelText: 'Durasi Iqomah (menit)'),
)
```
**Good (Slider):**
```dart
TVSlider(
label: 'Durasi Iqomah',
value: settings.iqomahDuration.inMinutes,
min: 5,
max: 20,
divisions: 15,
unit: 'menit',
onChanged: (value) => saveIqomahDuration(Duration(minutes: value)),
)
```
#### E. Time Picker
```dart
TVTimePicker(
label: 'Waktu Mulai Subuh',
value: settings.subuhStartTime,
onChanged: (value) => saveSubuhStartTime(value),
)
```
---
## 7. Visual Design Guidelines
### Focus Indicators
Focus indicators must be:
- **High contrast** (visible from 2-3 meters)
- **Animated** (smooth transitions, ~200ms)
- **Multi-layered** (border + background + shadow)
- **Consistent** (same pattern throughout app)
**Recommended Focus States:**
```dart
// Unfocused
border: 1px solid grey
background: white
shadow: none
// Focused
border: 3px solid primary_color
background: primary_color_10_percent
shadow: primary_color_30_percent_blur_20px
```
### Typography Scale
```dart
// Based on 1920px width baseline
Display (Clock): 120px // For large time displays
Heading: 48px // Screen titles
Title: 36px // Section headers, button labels
Body: 28px // Form labels, content
Caption: 22px // Metadata, hints
```
### Color Contrast
- **Minimum contrast ratio**: 4.5:1 (WCAG AA)
- **Recommended contrast ratio**: 7:1 (WCAG AAA)
- **Focus indication**: Always use high contrast
- **Error states**: Red with white text
- **Success states**: Green with white text
### Spacing System
```dart
// Based on 8px grid system
padding_xs: 8px // Tight spacing
padding_sm: 16px // Compact
padding_md: 24px // Default
padding_lg: 32px // Comfortable
padding_xl: 48px // Generous
```
---
## 8. Navigation Patterns
### D-Pad Navigation Flow
```
← [Focus] →
```
**Rules:**
1. **Natural flow**: Left-to-right, top-to-bottom
2. **Wrap around**: Last item → first item in same row
3. **Section boundaries**: Clear visual separators
4. **Skip separators**: Focus should jump over dividers
### Keyboard Shortcuts
```dart
// Standard Android TV shortcuts
KEY_ENTER / KEY_SELECT: Activate focused item
KEY_BACK: Go back / cancel
KEY_MENU: Show context menu
KEY_MEDIA_PLAY_PAUSE: Play/pause media
KEY_MEDIA_FAST_FORWARD: Skip forward
KEY_MEDIA_REWIND: Skip back
```
---
## 9. Feedback Systems
### Visual Feedback
1. **Focus indicator**: Border + background + shadow
2. **Pressed state**: Scale down slightly (0.95x)
3. **Loading state**: Progress indicator + "Loading..." text
4. **Success state**: Green checkmark + success message
5. **Error state**: Red X + error message
### Sound Feedback
```dart
// Sound effects for different interactions
focus.mp3: Subtle "click" when focus moves
select.mp3: Confirming "ding" when item activated
error.mp3: Low "buzz" for errors
success.mp3: Pleasant "chime" for success
```
**Implementation:**
```dart
class TVSoundService {
static final TVSoundService _instance = TVSoundService._internal();
factory TVSoundService() => _instance;
TVSoundService._internal();
final AudioPlayer _audioPlayer = AudioPlayer();
Future<void> playFocus() => _audioPlayer.play(AssetSource('sounds/focus.mp3'));
Future<void> playSelect() => _audioPlayer.play(AssetSource('sounds/select.mp3'));
Future<void> playError() => _audioPlayer.play(AssetSource('sounds/error.mp3'));
Future<void> playSuccess() => _audioPlayer.play(AssetSource('sounds/success.mp3'));
}
```
### Haptic Feedback
```dart
// For game controllers with vibration
if (_focusNode.hasFocus) {
HapticFeedback.lightImpact(); // Subtle vibration on focus
}
```
---
## 10. Performance Considerations
### Animation Performance
```dart
// Use AnimatedBuilder for efficient rebuilds
AnimatedBuilder(
animation: _animation,
builder: (context, child) {
return Opacity(
opacity: _animation.value,
child: child,
);
},
child: ExpensiveWidget(), // Built only once
)
```
### Lazy Loading
```dart
// Use ListView.builder for long lists
ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) {
return TVListItem(item: items[index]);
},
)
```
### Image Optimization
```dart
// Cache images, use placeholders
CachedNetworkImage(
imageUrl: url,
placeholder: (context, url) => TVLoadingIndicator(),
errorWidget: (context, url, error) => TVErrorIcon(),
)
```
---
## 11. Accessibility
### Screen Reader Support
```dart
// Add semantic labels
Semantics(
label: 'Nama masjid field',
value: settings.masjidName,
hint: 'Masukkan nama masjid',
child: TVTextField(...),
)
```
### High Contrast Mode
```dart
// Respect system high contrast setting
final isHighContrast = MediaQuery.of(context).highContrast;
if (isHighContrast) {
// Use black and white only
// Remove subtle gradients
// Increase border widths
}
```
### Font Scaling
```dart
// Respect user's font size preferences
final scaleFactor = MediaQuery.of(context).textScaleFactor;
Text(
'Hello',
style: TextStyle(fontSize: 28 * scaleFactor),
)
```
---
## 12. Implementation Roadmap
### Phase 1: Foundation (Week 1)
**Focus Management System**
- [ ] Create `TVFocusManager` widget
- [ ] Implement focus scope nodes
- [ ] Add focus listener utilities
- [ ] Create focus ring animation
- [ ] Test D-pad navigation
**Scaling System**
- [ ] Create `TVScaling` utility
- [ ] Implement responsive layout helpers
- [ ] Define typography scale
- [ ] Test on different screen sizes
### Phase 2: Core Widgets (Week 2)
**Form Widgets**
- [ ] `TVTextField` with focus management
- [ ] `TVSelectionField` with dropdown
- [ ] `TVButton` with multiple styles
- [ ] `TVSwitch` for toggles
- [ ] `TVSlider` for numeric values
**Layout Widgets**
- [ ] `TVDashboardTile` for grid items
- [ ] `TVListItem` for list items
- [ ] `TVCard` for containers
- [ ] `TVSection` for grouping
### Phase 3: Advanced Features (Week 3)
**Navigation & Layouts**
- [ ] Grid-based dashboard screen
- [ ] Wizard-style multi-step form
- [ ] Tabbed interface
- [ ] Breadcrumb navigation
**Input Optimization**
- [ ] `TVSearchSelectField` with search
- [ ] `TVTimePicker` for time selection
- [ ] `TVDatePicker` for date selection
- [ ] `TVColorPicker` for color selection
### Phase 4: Polish (Week 4)
**Feedback Systems**
- [ ] Sound effects (focus, select, error, success)
- [ ] Visual feedback animations
- [ ] Loading indicators
- [ ] Error state UIs
- [ ] Success state UIs
**Performance**
- [ ] Animation optimization
- [ ] Image caching
- [ ] Lazy loading
- [ ] Memory leak testing
**Accessibility**
- [ ] Screen reader support
- [ ] High contrast mode
- [ ] Font scaling
- [ ] Focus indicators for color blind users
---
## 13. Testing Guidelines
### Unit Tests
```dart
testWidgets('TVTextField should show focus indicator', (tester) async {
await tester.pumpWidget(
MaterialApp(home: TVTextField(label: 'Test')),
);
// Simulate focus
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
await tester.pump();
// Verify focus indicator is visible
expect(find.byType(Border), findsOneWidget);
});
```
### Integration Tests
```dart
testWidgets('Complete admin flow should work', (tester) async {
// Build admin screen
await tester.pumpWidget(MyApp());
// Navigate to location settings
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pumpAndSettle();
// Select city
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
await tester.pumpAndSettle();
// Verify selection was saved
expect(find.text('Kota Yogyakarta'), findsOneWidget);
});
```
### Manual Testing Checklist
- [ ] All widgets are focusable with D-pad
- [ ] Focus indicators are clearly visible
- [ ] Navigation flow is predictable
- [ ] Text input works (though awkward)
- [ ] Sound feedback plays correctly
- [ ] Animations are smooth (60fps)
- [ ] Forms can be completed without mouse/touch
- [ ] Error states are clear
- [ ] Success states provide feedback
- [ ] Back button exits correctly
---
## 14. Common Pitfalls to Avoid
### ❌ Don't
1. **Don't use small hit targets**: Everything must be at least 48x48dp
2. **Don't rely on hover states**: TV remotes don't have hover
3. **Don't use subtle gradients**: Hard to see from distance
4. **Don't require extensive typing**: It's frustrating on TV
5. **Don't use small fonts**: Under 24px is unreadable from 2m
6. **Don't hide navigation**: Always show clear focus indicators
7. **Don't use web-only patterns**: TV ≠ web browser
8. **Don't forget sound feedback**: Crucial for accessibility
9. **Don't ignore keyboard shortcuts**: Power users expect them
10. **Don't make users backtrack**: Forward-flow navigation only
### ✅ Do
1. **Do make focus obvious**: Large borders, backgrounds, shadows
2. **Do provide clear feedback**: Visual + audio + haptic
3. **Do minimize text input**: Use selectors, wizards, presets
4. **Do use large fonts**: Minimum 24px, recommended 28px+
5. **Do test on real TV**: Emulator ≠ real experience
6. **Do optimize for D-pad**: Natural navigation flow
7. **Do add sound effects: Helpful feedback
8. **Do use animations: Smooth transitions (200ms)
9. **Do provide shortcuts: Keyboard navigation
10. **Do think in steps: Break complex forms into wizards
---
## 15. Recommended Packages
```yaml
dependencies:
# TV-specific components
flutter_tencent_eui: ^latest # TV-optimized UI components
# Enhanced focus management
flutter_focus: ^latest # Better focus control
# Sound feedback
audioplayers: ^latest # Sound effects
# Animations
animations: ^latest # Smooth transition helpers
# Form validation
form_field_validator: ^latest # Reduce user errors
# Image handling
cached_network_image: ^latest # Image caching
```
---
## 16. Success Metrics
### UX Metrics
- **Time to complete common tasks**:
- Change mosque name: < 30 seconds
- Change location: < 2 minutes
- Sync monthly data: < 1 minute
- Update theme: < 45 seconds
- **Error rate**:
- Form validation errors: < 5%
- Navigation errors: < 1%
- Focus loss: < 2%
- **User satisfaction**:
- Task completion rate: > 95%
- User-reported frustration: < 10%
- Willingness to use again: > 90%
### Technical Metrics
- **Performance**:
- Focus change: < 16ms (60fps)
- Screen transition: < 200ms
- Form submission: < 500ms
- No frame drops during animations
- **Reliability**:
- No crashes in 24h operation
- No memory leaks
- No focus loss bugs
- Graceful error handling
---
## 17. Conclusion
Flutter is **fully capable** of building excellent TV admin dashboards. The key is:
1. **Embrace TV constraints** rather than fighting them
2. **Think in focus**, not clicks
3. **Minimize text input** through smart UI choices
4. **Provide clear feedback** at every interaction
5. **Test on real hardware** with actual users
The single-APK architecture is **totally viable** with these TV-optimized widgets. You don't need a separate phone app to deliver a great admin experience.
### Next Steps
1. **Start with focus management** - this is the foundation
2. **Build TV-optimized widgets** - large, clear, responsive
3. **Reduce text input** - use selectors and wizards
4. **Add polish** - sounds, animations, feedback
5. **Test thoroughly** - on real TV with real users
Your admin dashboard will be excellent!
---
## Appendix: Code Examples
### Complete TV TextField Example
See `/lib/ui/widgets/tv/tv_text_field.dart` for full implementation.
### Complete TV Selection Field Example
See `/lib/ui/widgets/tv/tv_selection_field.dart` for full implementation.
### Complete TV Dashboard Example
See `/lib/ui/screens/tv_admin_screen.dart` for full implementation.
---
**Document Version**: 1.0
**Last Updated**: 2025-01-XX
**Author**: Claude Code
**Status**: Ready for Implementation

1
analysis_options.yaml Normal file
View File

@@ -0,0 +1 @@
include: package:flutter_lints/flutter.yaml

14
android/.gitignore vendored Normal file
View File

@@ -0,0 +1,14 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks

View File

@@ -0,0 +1,44 @@
plugins {
id("com.android.application")
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
android {
namespace = "com.jamshalat.jamshalat_masjid_screen"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.jamshalat.jamshalat_masjid_screen"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
}
}
}
flutter {
source = "../.."
}

View File

@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@@ -0,0 +1,46 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<application
android:label="jamshalat_masjid_screen"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>

View File

@@ -0,0 +1,5 @@
package com.jamshalat.jamshalat_masjid_screen
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

24
android/build.gradle.kts Normal file
View File

@@ -0,0 +1,24 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}

View File

@@ -0,0 +1,2 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true

View File

@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip

View File

@@ -0,0 +1,28 @@
pluginManagement {
val flutterSdkPath =
run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.11.1" apply false
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
}
include(":app")
rootProject.name = "jamshalat_masjid_screen"

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

BIN
assets/images/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

BIN
assets/sounds/beep.mp3 Executable file

Binary file not shown.

130
brief.md Normal file
View File

@@ -0,0 +1,130 @@
# **Project Brief: Smart Digital Prayer Clock (Jam Shalat Masjid) for Android TV Box**
**Target Platform:** Android TV Box (Android 13/14, 4GB RAM, 64GB ROM) \- Landscape Display (1080p).
**Framework:** Flutter (Latest Stable).
**Architecture Pattern:** Clean Architecture or MVVM.
**State Management Suggestion:** Riverpod atau Provider (Pilih salah satu yang paling stabil).
**Local Storage:** Hive atau Isar (Untuk caching API bulanan & Settings).
## **1\. Project Overview**
Aplikasi ini adalah sistem *Digital Signage* untuk masjid yang akan menyala 24/7 non-stop pada layar TV yang terhubung ke Android Box. Aplikasi ini TIDAK boleh mengandalkan internet real-time. Internet hanya tersedia sebulan sekali (via tethering HP) untuk menarik data jadwal shalat dari API (Bulk 1 bulan) dan melakukan sinkronisasi jam sistem (NTP). Selebihnya, aplikasi berjalan 100% OFFLINE membaca database lokal.
## **2\. Core Features & App State Machine**
Aplikasi memiliki siklus layar (*Screen State Machine*) berdasarkan waktu saat ini (DateTime.now()):
1. **\[STATE: NORMAL / ROTATION\]** \-\> Ini adalah state *default*. Layar akan terus berotasi/berganti secara otomatis berdasarkan skema yang ditentukan di Settings. Terdapat 2 jenis screen dalam rotasi ini:
* **Screen A (Main Screen):** Khusus menampilkan Jam Digital (Current Time), Tanggal (Masehi & Hijriah), Baris Jadwal Shalat (5/6 waktu), dan Countdown detikan menuju shalat berikutnya. Memiliki background statis/dinamis yang telah ditentukan.
* **Screen B (Slideshow Screen):** Khusus menampilkan poster informasi/pengumuman dalam bentuk gambar penuh (JPG/PNG). Transisi antar gambar atau saat berpindah dari/ke Main Screen menggunakan *Fade In-Out* atau *Slide In-Out*.
* *Skema Rotasi:* Dinamis (misal: Main \-\> Slide 1 \-\> Main \-\> Slide 2 \-\> Main \-\> Slide 3, ATAU Main \-\> Slide 1 \-\> Slide 2 \-\> Main \-\> Slide 3 dst).
2. **\[STATE: MENUJU ADZAN\]** \-\> (Misal 10 menit sebelum waktu masuk) Rotasi *Slidehow* dihentikan paksa. Layar terkunci di **Main Screen**, fokus menampilkan hitung mundur menuju Adzan agar jamaah bersiap.
3. **\[STATE: ADZAN\]** \-\> Waktu masuk. Menampilkan tulisan besar "WAKTU ADZAN \[NAMA SHALAT\]", membunyikan alarm/beep pendek.
4. **\[STATE: MENUJU IQOMAH\]** \-\> Menampilkan hitung mundur Iqomah. Khusus hari Jumat, hitung mundur ini diganti tulisan "PERSIAPAN KHUTBAH".
5. **\[STATE: SHALAT/BLACK SCREEN\]** \-\> Setelah waktu Iqomah habis, layar berubah menjadi HITAM PEKAT (Blank) dengan hanya sedikit teks samar agar tidak mengganggu kekhusyukan shalat.
6. **\[STATE: KEMBALI NORMAL\]** \-\> Setelah durasi Shalat habis, layar kembali ke \[STATE: NORMAL / ROTATION\].
## **3\. Specific Logic Requirements**
### **A. Waktu Shalat & Logika Hari Jumat (Crucial\!)**
* Terdapat 6 jadwal harian: Imsak (opsional), Subuh, Terbit/Syuruq, Dzuhur, Ashar, Maghrib, Isya.
* **Logika Hari Jumat:** JIKA DateTime.now().weekday \== DateTime.friday, MAKA jadwal "Dzuhur" HARUS diubah string/labelnya menjadi "JUMAT".
* Khusus masuk waktu "JUMAT", State \[MENUJU IQOMAH\] di-bypass atau diganti dengan tampilan Nama Khatib dan Imam.
### **B. Offline-First API Fetching**
* Buat satu halaman **Admin/Settings Page** (tersembunyi, diakses dengan menekan pojok layar 5 kali atau via icon gembok).
* Di Admin Page, ada tombol "Sync Data Bulanan".
* Saat ditekan, fetch API \-\> Parse JSON \-\> Simpan ke Hive/Isar \-\> Update last\_sync\_date.
* Di UI utama, timer HANYA boleh membaca dari database lokal, bukan nembak ke API.
### **C. Watchdog & Anti-Freeze (Device Specific)**
* Wajib menggunakan package wakelock\_plus dan panggil WakelockPlus.enable() di main.dart agar TV Box tidak masuk mode *Sleep*.
* Gunakan Timer.periodic yang efisien.
## **4\. Controllable Settings (Dynamic Variables)**
Semua variabel di bawah ini TIDAK BOLEH *hardcoded*. Buatkan model class AppSettings yang disimpan di Local Storage:
{
"masjid\_name": "Masjid Al-Ikhlas",
"masjid\_address": "Jl. Kebaikan No. 1, Padang",
"city\_id\_api": "1234",
"show\_imsak": true,
"show\_terbit": true,
"iqomah\_durations": {
"subuh": 15,
"dzuhur": 10,
"ashar": 10,
"maghrib": 7,
"isya": 10
},
"blank\_screen\_durations": {
"normal": 15,
"jumat": 45
},
"running\_text": \[
"Mohon luruskan dan rapatkan shaf",
"Kajian rutin setiap Ahad pagi"
\],
"jumat\_officers": {
"khatib": "Ust. Fulan, S.Ag",
"imam": "Ust. Alan, Lc"
},
"theme\_color": "0xFF006400",
"main\_screen\_background": "/path/to/local/bg.jpg",
"slideshow\_images": \[
"/path/to/local/slide1.jpg",
"/path/to/local/slide2.png"
\],
"rotation\_settings": {
"main\_screen\_duration\_sec": 15,
"slide\_duration\_sec": 10,
"sequence\_pattern": \["main", "slide\_1", "main", "slide\_2"\]
}
}
*(Catatan untuk AI: sequence\_pattern bisa diimplementasikan sebagai logic antrean berulang yang membaca array tersebut).*
## **5\. UI/UX Layout Structure (Landscape 16:9)**
UI harus dibuat menjadi 2 komponen terpisah yang saling bergantian (menggunakan AnimatedSwitcher untuk transisi *Fade*):
### **A. Komponen: Main Screen (Jam & Jadwal)**
* **Background:** Gambar statis yang di-set dari Settings, ditambah overlay gradient gelap agar teks terbaca jelas.
* **Top Area (Header):** Nama Masjid, Alamat, dan Logo.
* **Center Area (Fokus Utama):**
* Jam Digital berukuran sangat besar (mendominasi tengah layar).
* Tanggal Masehi & Tanggal Hijriah tepat di bawah jam.
* Teks dinamis: "Menuju \[Nama Shalat Berikutnya\] \- \[Hitung Mundur HH:MM:SS\]".
* **Bottom Area (Footer):** Row horizontal berisi kotak/kartu jadwal shalat hari ini. Kotak shalat yang akan datang diberi highlight warna berbeda.
* **Bottom Edge:** Marquee (Running Text) berjalan tanpa henti.
### **B. Komponen: Slideshow Screen (Informasi Poster)**
* **Center Area:** Gambar poster (JPG/PNG) dirender secara *fullscreen* (atau *fit center* dengan proporsi terjaga).
* **Floating Information Widget (Overlay):** Sebuah *box* melayang di pojok layar (misal pojok kanan atas) dengan *background dark transparent* (opacity 35% \- 50%). Widget ini menampilkan:
* Jam Digital (Current Time) dalam ukuran sedang.
* Nama Shalat Mendatang (Hanya filter 5 shalat fardhu: Subuh, Dzuhur/Jumat, Ashar, Maghrib, Isya. Abaikan waktu Imsak dan Terbit di widget ini).
* Countdown Timer menuju waktu shalat tersebut.
* **Bottom Edge:** Marquee (Running Text) HARUS tetap terlihat meskipun sedang dalam mode Slideshow, agar pengumuman darurat tetap terbaca.
## **6\. AI Output Instructions**
Tolong generate kode dengan urutan berikut:
1. pubspec.yaml dependencies.
2. Model class untuk Settings & Jadwal Shalat.
3. Service layer untuk Local Storage (Hive/SharedPrefs).
4. State Machine Logic (Timer provider/manager untuk handle transisi State, TERMASUK logic *Rotation Scheme* antara Main Screen dan Slideshow).
5. UI Layout: Pisahkan MainScreenWidget dan SlideshowWidget, lalu gabungkan di HomeView dengan AnimatedSwitcher.
6. Admin Screen sederhana untuk mengatur variabel Controllable Settings.
Pastikan kodenya *null-safe* dan efisien dalam penggunaan memori (hindari memory leaks pada Timer rotasi maupun interval).

34
ios/.gitignore vendored Normal file
View File

@@ -0,0 +1,34 @@
**/dgph
*.mode1v3
*.mode2v3
*.moved-aside
*.pbxuser
*.perspectivev3
**/*sync/
.sconsign.dblite
.tags*
**/.vagrant/
**/DerivedData/
Icon?
**/Pods/
**/.symlinks/
profile
xcuserdata
**/.generated/
Flutter/App.framework
Flutter/Flutter.framework
Flutter/Flutter.podspec
Flutter/Generated.xcconfig
Flutter/ephemeral/
Flutter/app.flx
Flutter/app.zip
Flutter/flutter_assets/
Flutter/flutter_export_environment.sh
ServiceDefinitions.json
Runner/GeneratedPluginRegistrant.*
# Exceptions to above rules.
!default.mode1v3
!default.mode2v3
!default.pbxuser
!default.perspectivev3

View File

@@ -0,0 +1,24 @@
<?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>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>App</string>
<key>CFBundleIdentifier</key>
<string>io.flutter.flutter.app</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>App</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
</dict>
</plist>

View File

@@ -0,0 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "Generated.xcconfig"

View File

@@ -0,0 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "Generated.xcconfig"

43
ios/Podfile Normal file
View File

@@ -0,0 +1,43 @@
# Uncomment this line to define a global platform for your project
# platform :ios, '13.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
project 'Runner', {
'Debug' => :debug,
'Profile' => :release,
'Release' => :release,
}
def flutter_root
generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__)
unless File.exist?(generated_xcode_build_settings_path)
raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first"
end
File.foreach(generated_xcode_build_settings_path) do |line|
matches = line.match(/FLUTTER_ROOT\=(.*)/)
return matches[1].strip if matches
end
raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get"
end
require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
flutter_ios_podfile_setup
target 'Runner' do
use_frameworks!
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
target 'RunnerTests' do
inherit! :search_paths
end
end
post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target)
end
end

View File

@@ -0,0 +1,620 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
proxyType = 1;
remoteGlobalIDString = 97C146ED1CF9000F007C117D;
remoteInfo = Runner;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
9705A1C41CF9048500538489 /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 10;
files = (
);
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
97C146EB1CF9000F007C117D /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
331C8082294A63A400263BE5 /* RunnerTests */ = {
isa = PBXGroup;
children = (
331C807B294A618700263BE5 /* RunnerTests.swift */,
);
path = RunnerTests;
sourceTree = "<group>";
};
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
9740EEB31CF90195004384FC /* Generated.xcconfig */,
);
name = Flutter;
sourceTree = "<group>";
};
97C146E51CF9000F007C117D = {
isa = PBXGroup;
children = (
9740EEB11CF90186004384FC /* Flutter */,
97C146F01CF9000F007C117D /* Runner */,
97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */,
);
sourceTree = "<group>";
};
97C146EF1CF9000F007C117D /* Products */ = {
isa = PBXGroup;
children = (
97C146EE1CF9000F007C117D /* Runner.app */,
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup;
children = (
97C146FA1CF9000F007C117D /* Main.storyboard */,
97C146FD1CF9000F007C117D /* Assets.xcassets */,
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
97C147021CF9000F007C117D /* Info.plist */,
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
);
path = Runner;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
331C8080294A63A400263BE5 /* RunnerTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
buildPhases = (
331C807D294A63A400263BE5 /* Sources */,
331C807F294A63A400263BE5 /* Resources */,
);
buildRules = (
);
dependencies = (
331C8086294A63A400263BE5 /* PBXTargetDependency */,
);
name = RunnerTests;
productName = RunnerTests;
productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
97C146ED1CF9000F007C117D /* Runner */ = {
isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
9740EEB61CF901F6004384FC /* Run Script */,
97C146EA1CF9000F007C117D /* Sources */,
97C146EB1CF9000F007C117D /* Frameworks */,
97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
);
buildRules = (
);
dependencies = (
);
name = Runner;
productName = Runner;
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
97C146E61CF9000F007C117D /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1510;
ORGANIZATIONNAME = "";
TargetAttributes = {
331C8080294A63A400263BE5 = {
CreatedOnToolsVersion = 14.0;
TestTargetID = 97C146ED1CF9000F007C117D;
};
97C146ED1CF9000F007C117D = {
CreatedOnToolsVersion = 7.3.1;
LastSwiftMigration = 1100;
};
};
};
buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
compatibilityVersion = "Xcode 9.3";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 97C146E51CF9000F007C117D;
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
97C146ED1CF9000F007C117D /* Runner */,
331C8080294A63A400263BE5 /* RunnerTests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
331C807F294A63A400263BE5 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EC1CF9000F007C117D /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
);
name = "Thin Binary";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
};
9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "Run Script";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
331C807D294A63A400263BE5 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EA1CF9000F007C117D /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 97C146ED1CF9000F007C117D /* Runner */;
targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
97C146FA1CF9000F007C117D /* Main.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C146FB1CF9000F007C117D /* Base */,
);
name = Main.storyboard;
sourceTree = "<group>";
};
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C147001CF9000F007C117D /* Base */,
);
name = LaunchScreen.storyboard;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
249021D3217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Profile;
};
249021D4217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.jamshalat.jamshalatMasjidScreen;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Profile;
};
331C8088294A63A400263BE5 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.jamshalat.jamshalatMasjidScreen.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Debug;
};
331C8089294A63A400263BE5 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.jamshalat.jamshalatMasjidScreen.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Release;
};
331C808A294A63A400263BE5 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.jamshalat.jamshalatMasjidScreen.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Profile;
};
97C147031CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
97C147041CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Release;
};
97C147061CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.jamshalat.jamshalatMasjidScreen;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Debug;
};
97C147071CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.jamshalat.jamshalatMasjidScreen;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
331C8088294A63A400263BE5 /* Debug */,
331C8089294A63A400263BE5 /* Release */,
331C808A294A63A400263BE5 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
97C147031CF9000F007C117D /* Debug */,
97C147041CF9000F007C117D /* Release */,
249021D3217E4FDB00AE95B9 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
97C147061CF9000F007C117D /* Debug */,
97C147071CF9000F007C117D /* Release */,
249021D4217E4FDB00AE95B9 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 97C146E61CF9000F007C117D /* Project object */;
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@@ -0,0 +1,8 @@
<?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>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View File

@@ -0,0 +1,8 @@
<?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>PreviewsEnabled</key>
<false/>
</dict>
</plist>

View File

@@ -0,0 +1,101 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1510"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "331C8080294A63A400263BE5"
BuildableName = "RunnerTests.xctest"
BlueprintName = "RunnerTests"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
enableGPUValidationMode = "1"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Profile"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
</Workspace>

View File

@@ -0,0 +1,8 @@
<?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>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View File

@@ -0,0 +1,8 @@
<?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>PreviewsEnabled</key>
<false/>
</dict>
</plist>

View File

@@ -0,0 +1,16 @@
import Flutter
import UIKit
@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
}
}

View File

@@ -0,0 +1,122 @@
{
"images" : [
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "Icon-App-20x20@3x.png",
"scale" : "3x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "Icon-App-29x29@3x.png",
"scale" : "3x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "Icon-App-40x40@3x.png",
"scale" : "3x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@2x.png",
"scale" : "2x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "Icon-App-60x60@3x.png",
"scale" : "3x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@1x.png",
"scale" : "1x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "Icon-App-20x20@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "Icon-App-29x29@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@1x.png",
"scale" : "1x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "Icon-App-40x40@2x.png",
"scale" : "2x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@1x.png",
"scale" : "1x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "Icon-App-76x76@2x.png",
"scale" : "2x"
},
{
"size" : "83.5x83.5",
"idiom" : "ipad",
"filename" : "Icon-App-83.5x83.5@2x.png",
"scale" : "2x"
},
{
"size" : "1024x1024",
"idiom" : "ios-marketing",
"filename" : "Icon-App-1024x1024@1x.png",
"scale" : "1x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,23 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "LaunchImage.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@2x.png",
"scale" : "2x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@3x.png",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

View File

@@ -0,0 +1,5 @@
# Launch Screen Assets
You can customize the launch screen with your own desired assets by replacing the image files in this directory.
You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images.

View File

@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/>
<viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
</imageView>
</subviews>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
</constraints>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
</scene>
</scenes>
<resources>
<image name="LaunchImage" width="168" height="185"/>
</resources>
</document>

View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
</dependencies>
<scenes>
<!--Flutter View Controller-->
<scene sceneID="tne-QT-ifu">
<objects>
<viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
</document>

70
ios/Runner/Info.plist Normal file
View File

@@ -0,0 +1,70 @@
<?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>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Jamshalat Masjid Screen</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>jamshalat_masjid_screen</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneConfigurationName</key>
<string>flutter</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>

View File

@@ -0,0 +1 @@
#import "GeneratedPluginRegistrant.h"

View File

@@ -0,0 +1,6 @@
import Flutter
import UIKit
class SceneDelegate: FlutterSceneDelegate {
}

View File

@@ -0,0 +1,12 @@
import Flutter
import UIKit
import XCTest
class RunnerTests: XCTestCase {
func testExample() {
// If you add code to the Runner application, consider adding tests here.
// See https://developer.apple.com/documentation/xctest for more information about using XCTest.
}
}

52
lib/core/enums.dart Normal file
View File

@@ -0,0 +1,52 @@
/// App-wide state enums for the screen state machine.
library;
/// The 6 states the TV display cycles through.
enum ScreenState {
/// Normal rotation between MainScreen and Slideshow.
normal,
/// Pre-Adzan: Lock to MainScreen, show countdown.
menujuAdzan,
/// Adzan alert: Full-screen takeover.
adzan,
/// Iqomah countdown (or Friday Khutbah info).
menujuIqomah,
/// Black screen during prayer.
shalat,
/// Transitioning back to normal after prayer.
kembaliNormal,
}
/// Named prayer slots used across the app.
enum PrayerName {
imsak,
subuh,
terbit,
dhuha,
dzuhur,
ashar,
maghrib,
isya;
/// Display label — handles Friday override.
String displayLabel({bool isFriday = false}) {
if (this == PrayerName.dzuhur && isFriday) return 'JUMAT';
return id[0].toUpperCase() + id.substring(1);
}
/// Safe string identifier to replace .name
String get id => toString().split('.').last;
/// Whether this is a fardhu prayer (has iqomah).
bool get isFardhu =>
this == PrayerName.subuh ||
this == PrayerName.dzuhur ||
this == PrayerName.ashar ||
this == PrayerName.maghrib ||
this == PrayerName.isya;
}

74
lib/core/hijri_date.dart Normal file
View File

@@ -0,0 +1,74 @@
class HijriDate {
final int year;
final int month;
final int day;
const HijriDate({
required this.year,
required this.month,
required this.day,
});
}
class HijriDateFormatter {
HijriDateFormatter._();
static const List<String> _monthNames = [
'Muharram',
'Safar',
'Rabiul Awal',
'Rabiul Akhir',
'Jumadil Awal',
'Jumadil Akhir',
'Rajab',
'Syaban',
'Ramadan',
'Syawal',
'Zulkaidah',
'Zulhijah',
];
static String format(DateTime date) {
final hijri = fromGregorian(date);
final monthName = _monthNames[hijri.month - 1];
return '${hijri.day} $monthName ${hijri.year} H';
}
// Tabular Islamic calendar conversion. This is deterministic and avoids
// adding a new dependency just to replace the hardcoded placeholder dates.
static HijriDate fromGregorian(DateTime date) {
final jd = _gregorianToJulianDay(date.year, date.month, date.day);
var l = jd - 1948440 + 10632;
final n = ((l - 1) ~/ 10631);
l = l - 10631 * n + 354;
final j = (((10985 - l) ~/ 5316) * ((50 * l) ~/ 17719)) +
((l ~/ 5670) * ((43 * l) ~/ 15238));
l = l -
(((30 - j) ~/ 15) * ((17719 * j) ~/ 50)) -
((j ~/ 16) * ((15238 * j) ~/ 43)) +
29;
final month = (24 * l) ~/ 709;
final day = l - ((709 * month) ~/ 24);
final year = 30 * n + j - 30;
return HijriDate(year: year, month: month, day: day);
}
static int _gregorianToJulianDay(int year, int month, int day) {
final a = (14 - month) ~/ 12;
final y = year + 4800 - a;
final m = month + 12 * a - 3;
return day +
((153 * m + 2) ~/ 5) +
365 * y +
(y ~/ 4) -
(y ~/ 100) +
(y ~/ 400) -
32045;
}
}

174
lib/core/sacred_tokens.dart Normal file
View File

@@ -0,0 +1,174 @@
/// Design tokens extracted from "The Sacred Horizon" Stitch design system.
/// These are the canonical color, typography, spacing, and styling constants.
library;
import 'package:flutter/material.dart';
// ──────────────────────────────────────────────
// COLOR TOKENS — "Masjid Twilight" palette
// ──────────────────────────────────────────────
class SacredColors {
SacredColors._();
// Core
static const Color primary = Color(0xFF88D982); // Living Green
static const Color primaryContainer = Color(0xFF004F11);
static const Color onPrimaryContainer = Color(0xFF72C36E);
static const Color secondary = Color(0xFFE9C349); // Sacred Gold
static const Color secondaryContainer = Color(0xFFAF8D11);
static const Color tertiary = Color(0xFFE9C400);
static const Color tertiaryContainer = Color(0xFFC8A900);
// Background & Surface hierarchy
static const Color background = Color(0xFF131313); // The Void
static const Color surface = Color(0xFF131313);
static const Color surfaceDim = Color(0xFF131313);
static const Color surfaceContainerLowest = Color(0xFF0E0E0E);
static const Color surfaceContainerLow = Color(0xFF1C1B1B);
static const Color surfaceContainer = Color(0xFF201F1F);
static const Color surfaceContainerHigh = Color(0xFF2A2A2A);
static const Color surfaceContainerHighest = Color(0xFF353534);
static const Color surfaceBright = Color(0xFF393939);
static const Color surfaceVariant = Color(0xFF353534);
// On tokens
static const Color onSurface = Color(0xFFE5E2E1);
static const Color onSurfaceVariant = Color(0xFFBFC9C4);
static const Color onBackground = Color(0xFFE5E2E1);
static const Color onPrimary = Color(0xFF003909);
static const Color onSecondary = Color(0xFF3C2F00);
// Outline
static const Color outline = Color(0xFF89938F);
static const Color outlineVariant = Color(0xFF3F4945);
// Error
static const Color error = Color(0xFFFFB4AB);
static const Color errorContainer = Color(0xFF93000A);
// Inverse
static const Color inverseSurface = Color(0xFFE5E2E1);
static const Color inversePrimary = Color(0xFF1B6D24);
// Special
static const Color blackScreen = Color(0xFF000000);
// Convenience: transparent glass for overlays
static Color get glass70 => surfaceVariant.withValues(alpha: 0.70);
static Color get glass60 => surfaceVariant.withValues(alpha: 0.60);
static Color get glass35 => surfaceVariant.withValues(alpha: 0.35);
}
// ──────────────────────────────────────────────
// TYPOGRAPHY
// ──────────────────────────────────────────────
class SacredTypography {
SacredTypography._();
static const String headlineFamily = 'Plus Jakarta Sans';
static const String bodyFamily = 'Manrope';
// Display — The Clock (heartbeat of the system)
static TextStyle clockDisplay(double fontSize) => TextStyle(
fontFamily: headlineFamily,
fontSize: fontSize,
fontWeight: FontWeight.w800,
letterSpacing: -0.02 * fontSize,
color: SacredColors.onSurface,
height: 1.0,
);
// Headline — Prayer Names
static TextStyle headline(double fontSize) => TextStyle(
fontFamily: headlineFamily,
fontSize: fontSize,
fontWeight: FontWeight.w700,
letterSpacing: -0.01 * fontSize,
color: SacredColors.onSurface,
);
// Title — Timings
static TextStyle title(double fontSize) => TextStyle(
fontFamily: bodyFamily,
fontSize: fontSize,
fontWeight: FontWeight.w600,
color: SacredColors.onSurface,
);
// Body
static TextStyle body(double fontSize) => TextStyle(
fontFamily: bodyFamily,
fontSize: fontSize,
fontWeight: FontWeight.w400,
color: SacredColors.onSurface,
);
// Label — metadata
static TextStyle label(double fontSize) => TextStyle(
fontFamily: bodyFamily,
fontSize: fontSize,
fontWeight: FontWeight.w500,
letterSpacing: 0.05 * fontSize,
color: SacredColors.onSurfaceVariant,
);
// Label caps (for TRACKING-WIDEST uppercase captions)
static TextStyle labelCaps(double fontSize) => TextStyle(
fontFamily: bodyFamily,
fontSize: fontSize,
fontWeight: FontWeight.w700,
letterSpacing: 0.2 * fontSize,
color: SacredColors.onSurfaceVariant,
);
}
// ──────────────────────────────────────────────
// SPACING — 8px grid
// ──────────────────────────────────────────────
class SacredSpacing {
SacredSpacing._();
static const double xs = 8;
static const double sm = 16;
static const double md = 24;
static const double lg = 32;
static const double xl = 48;
static const double xxl = 64;
static const double screenEdge = 64; // 4rem minimum from edges
}
// ──────────────────────────────────────────────
// RADII
// ──────────────────────────────────────────────
class SacredRadii {
SacredRadii._();
static const double sm = 4;
static const double md = 8;
static const double lg = 12;
static const double xl = 16;
static const double full = 9999;
}
// ──────────────────────────────────────────────
// TV SCALING
// ──────────────────────────────────────────────
class TVScale {
TVScale._();
/// Scale factor relative to 1920px baseline
static double of(BuildContext context) {
return MediaQuery.of(context).size.width / 1920;
}
static double fontSize(BuildContext context, double base) {
return base * of(context);
}
}

420
lib/data/local/models.dart Normal file
View File

@@ -0,0 +1,420 @@
import 'package:hive_flutter/hive_flutter.dart';
/// Hive type adapter IDs and box names.
class HiveBoxes {
HiveBoxes._();
static const String settings = 'app_settings';
static const String prayerSchedule = 'prayer_schedule';
}
/// AppSettings stored in Hive.
@HiveType(typeId: 0)
class AppSettings extends HiveObject {
@HiveField(0)
String masjidName;
@HiveField(1)
String masjidAddress;
@HiveField(2)
String cityIdApi; // myQuran city hash ID
@HiveField(3)
String cityDisplayName;
@HiveField(4)
bool showImsak;
@HiveField(5)
bool showTerbit;
// Iqomah durations in minutes
@HiveField(6)
int iqomahSubuh;
@HiveField(7)
int iqomahDzuhur;
@HiveField(8)
int iqomahAshar;
@HiveField(9)
int iqomahMaghrib;
@HiveField(10)
int iqomahIsya;
// Pre-Adzan lead time (minutes before adzan to lock main screen)
@HiveField(11)
int preAdzanLead;
// Blank screen durations
@HiveField(12)
int blankScreenNormal; // minutes
@HiveField(13)
int blankScreenJumat; // minutes
// Running text items
@HiveField(14)
List<String> runningTexts;
// Friday officers
@HiveField(15)
String khatibName;
@HiveField(16)
String imamName;
// Rotation settings
@HiveField(17)
int mainScreenDurationSec;
@HiveField(18)
int slideDurationSec;
// Last sync timestamp
@HiveField(19)
String? lastSyncDate;
// Slideshow image paths (local)
@HiveField(20)
List<String> slideshowImages;
// Text scaling (0=Small, 1=Medium, 2=Large)
@HiveField(21)
int textScaleIndex;
// Unsplash Background configs
@HiveField(22)
bool useUnsplashBackground;
@HiveField(23)
String unsplashKeyword;
@HiveField(24)
int unsplashRotationHours;
// Branded background image (local file path set by admin)
@HiveField(25)
String? brandedBgImage;
// Per-item duration for running texts (seconds each)
@HiveField(26)
List<int> runningTextDurations;
// Running text animation type: 'marquee' or 'fade'
@HiveField(27)
String marqueeAnimType;
// Granular text group scales (independent of textScaleIndex)
// Group: Prayer card label (e.g. "SUBUH", "DZUHUR")
@HiveField(28)
double scaleCardLabel;
// Group: Prayer card body (time + iqomah text)
@HiveField(29)
double scaleCardBody;
// Group: Running text ticker at bottom
@HiveField(30)
double scaleRunningText;
AppSettings({
this.masjidName = 'Masjid Al-Ikhlas',
this.masjidAddress = 'Jl. Kebaikan No. 1',
this.cityIdApi = '1218', // Default: Yogyakarta
this.cityDisplayName = 'Kota Yogyakarta',
this.showImsak = true,
this.showTerbit = true,
this.iqomahSubuh = 15,
this.iqomahDzuhur = 10,
this.iqomahAshar = 10,
this.iqomahMaghrib = 7,
this.iqomahIsya = 10,
this.preAdzanLead = 10,
this.blankScreenNormal = 15,
this.blankScreenJumat = 45,
this.runningTexts = const [
'Mohon luruskan dan rapatkan shaf',
'Kajian rutin setiap Ahad pagi',
],
this.khatibName = 'Ust. Fulan, S.Ag',
this.imamName = 'Ust. Alan, Lc',
this.mainScreenDurationSec = 15,
this.slideDurationSec = 10,
this.lastSyncDate,
this.slideshowImages = const [],
this.textScaleIndex = 1,
this.useUnsplashBackground = false,
this.unsplashKeyword = 'mosque',
this.unsplashRotationHours = 6,
this.brandedBgImage,
this.runningTextDurations = const [],
this.marqueeAnimType = 'marquee',
this.scaleCardLabel = 1.0,
this.scaleCardBody = 1.0,
this.scaleRunningText = 1.0,
});
AppSettings copyWith({
String? masjidName,
String? masjidAddress,
String? cityIdApi,
String? cityDisplayName,
bool? showImsak,
bool? showTerbit,
int? iqomahSubuh,
int? iqomahDzuhur,
int? iqomahAshar,
int? iqomahMaghrib,
int? iqomahIsya,
int? preAdzanLead,
int? blankScreenNormal,
int? blankScreenJumat,
List<String>? runningTexts,
String? khatibName,
String? imamName,
int? mainScreenDurationSec,
int? slideDurationSec,
String? lastSyncDate,
List<String>? slideshowImages,
int? textScaleIndex,
bool? useUnsplashBackground,
String? unsplashKeyword,
int? unsplashRotationHours,
String? brandedBgImage,
List<int>? runningTextDurations,
String? marqueeAnimType,
double? scaleCardLabel,
double? scaleCardBody,
double? scaleRunningText,
}) {
return AppSettings(
masjidName: masjidName ?? this.masjidName,
masjidAddress: masjidAddress ?? this.masjidAddress,
cityIdApi: cityIdApi ?? this.cityIdApi,
cityDisplayName: cityDisplayName ?? this.cityDisplayName,
showImsak: showImsak ?? this.showImsak,
showTerbit: showTerbit ?? this.showTerbit,
iqomahSubuh: iqomahSubuh ?? this.iqomahSubuh,
iqomahDzuhur: iqomahDzuhur ?? this.iqomahDzuhur,
iqomahAshar: iqomahAshar ?? this.iqomahAshar,
iqomahMaghrib: iqomahMaghrib ?? this.iqomahMaghrib,
iqomahIsya: iqomahIsya ?? this.iqomahIsya,
preAdzanLead: preAdzanLead ?? this.preAdzanLead,
blankScreenNormal: blankScreenNormal ?? this.blankScreenNormal,
blankScreenJumat: blankScreenJumat ?? this.blankScreenJumat,
runningTexts: runningTexts ?? this.runningTexts,
khatibName: khatibName ?? this.khatibName,
imamName: imamName ?? this.imamName,
mainScreenDurationSec: mainScreenDurationSec ?? this.mainScreenDurationSec,
slideDurationSec: slideDurationSec ?? this.slideDurationSec,
lastSyncDate: lastSyncDate ?? this.lastSyncDate,
slideshowImages: slideshowImages ?? this.slideshowImages,
textScaleIndex: textScaleIndex ?? this.textScaleIndex,
useUnsplashBackground: useUnsplashBackground ?? this.useUnsplashBackground,
unsplashKeyword: unsplashKeyword ?? this.unsplashKeyword,
unsplashRotationHours: unsplashRotationHours ?? this.unsplashRotationHours,
brandedBgImage: brandedBgImage ?? this.brandedBgImage,
runningTextDurations: runningTextDurations ?? this.runningTextDurations,
marqueeAnimType: marqueeAnimType ?? this.marqueeAnimType,
scaleCardLabel: scaleCardLabel ?? this.scaleCardLabel,
scaleCardBody: scaleCardBody ?? this.scaleCardBody,
scaleRunningText: scaleRunningText ?? this.scaleRunningText,
);
}
}
/// Adapter for AppSettings — hand-written to avoid code generation.
class AppSettingsAdapter extends TypeAdapter<AppSettings> {
@override
final int typeId = 0;
@override
AppSettings read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{};
for (int i = 0; i < numOfFields; i++) {
fields[reader.readByte()] = reader.read();
}
return AppSettings(
masjidName: fields[0] as String? ?? 'Masjid Al-Ikhlas',
masjidAddress: fields[1] as String? ?? 'Jl. Kebaikan No. 1',
cityIdApi: fields[2] as String? ?? '1218',
cityDisplayName: fields[3] as String? ?? 'Kota Yogyakarta',
showImsak: fields[4] as bool? ?? true,
showTerbit: fields[5] as bool? ?? true,
iqomahSubuh: fields[6] as int? ?? 15,
iqomahDzuhur: fields[7] as int? ?? 10,
iqomahAshar: fields[8] as int? ?? 10,
iqomahMaghrib: fields[9] as int? ?? 7,
iqomahIsya: fields[10] as int? ?? 10,
preAdzanLead: fields[11] as int? ?? 10,
blankScreenNormal: fields[12] as int? ?? 15,
blankScreenJumat: fields[13] as int? ?? 45,
runningTexts: (fields[14] as List?)?.cast<String>() ?? const [],
khatibName: fields[15] as String? ?? '',
imamName: fields[16] as String? ?? '',
mainScreenDurationSec: fields[17] as int? ?? 15,
slideDurationSec: fields[18] as int? ?? 10,
lastSyncDate: fields[19] as String?,
slideshowImages: (fields[20] as List?)?.cast<String>() ?? const [],
textScaleIndex: fields[21] as int? ?? 1,
useUnsplashBackground: fields[22] as bool? ?? false,
unsplashKeyword: fields[23] as String? ?? 'mosque',
unsplashRotationHours: fields[24] as int? ?? 6,
brandedBgImage: fields[25] as String?,
runningTextDurations: (fields[26] as List?)?.cast<int>() ?? const [],
marqueeAnimType: fields[27] as String? ?? 'marquee',
scaleCardLabel: (fields[28] as num?)?.toDouble() ?? 1.0,
scaleCardBody: (fields[29] as num?)?.toDouble() ?? 1.0,
scaleRunningText: (fields[30] as num?)?.toDouble() ?? 1.0,
);
}
@override
void write(BinaryWriter writer, AppSettings obj) {
writer
..writeByte(31)
..writeByte(0)..write(obj.masjidName)
..writeByte(1)..write(obj.masjidAddress)
..writeByte(2)..write(obj.cityIdApi)
..writeByte(3)..write(obj.cityDisplayName)
..writeByte(4)..write(obj.showImsak)
..writeByte(5)..write(obj.showTerbit)
..writeByte(6)..write(obj.iqomahSubuh)
..writeByte(7)..write(obj.iqomahDzuhur)
..writeByte(8)..write(obj.iqomahAshar)
..writeByte(9)..write(obj.iqomahMaghrib)
..writeByte(10)..write(obj.iqomahIsya)
..writeByte(11)..write(obj.preAdzanLead)
..writeByte(12)..write(obj.blankScreenNormal)
..writeByte(13)..write(obj.blankScreenJumat)
..writeByte(14)..write(obj.runningTexts)
..writeByte(15)..write(obj.khatibName)
..writeByte(16)..write(obj.imamName)
..writeByte(17)..write(obj.mainScreenDurationSec)
..writeByte(18)..write(obj.slideDurationSec)
..writeByte(19)..write(obj.lastSyncDate)
..writeByte(20)..write(obj.slideshowImages)
..writeByte(21)..write(obj.textScaleIndex)
..writeByte(22)..write(obj.useUnsplashBackground)
..writeByte(23)..write(obj.unsplashKeyword)
..writeByte(24)..write(obj.unsplashRotationHours)
..writeByte(25)..write(obj.brandedBgImage)
..writeByte(26)..write(obj.runningTextDurations)
..writeByte(27)..write(obj.marqueeAnimType)
..writeByte(28)..write(obj.scaleCardLabel)
..writeByte(29)..write(obj.scaleCardBody)
..writeByte(30)..write(obj.scaleRunningText);
}
}
/// Daily prayer schedule row cached from the MyQuran API.
@HiveType(typeId: 1)
class DailyPrayerSchedule extends HiveObject {
@HiveField(0)
String date; // yyyy-MM-dd
@HiveField(1)
String imsak;
@HiveField(2)
String subuh;
@HiveField(3)
String terbit;
@HiveField(4)
String dhuha;
@HiveField(5)
String dzuhur;
@HiveField(6)
String ashar;
@HiveField(7)
String maghrib;
@HiveField(8)
String isya;
DailyPrayerSchedule({
required this.date,
required this.imsak,
required this.subuh,
required this.terbit,
required this.dhuha,
required this.dzuhur,
required this.ashar,
required this.maghrib,
required this.isya,
});
/// Parse time string "HH:mm" to a DateTime on the given date.
DateTime timeToDateTime(String time, DateTime refDate) {
final parts = time.split(':');
return DateTime(
refDate.year,
refDate.month,
refDate.day,
int.parse(parts[0]),
int.parse(parts[1]),
);
}
/// Get all prayer times as DateTime map for a given reference date.
Map<String, DateTime> toDateTimeMap(DateTime refDate) => {
'imsak': timeToDateTime(imsak, refDate),
'subuh': timeToDateTime(subuh, refDate),
'terbit': timeToDateTime(terbit, refDate),
'dhuha': timeToDateTime(dhuha, refDate),
'dzuhur': timeToDateTime(dzuhur, refDate),
'ashar': timeToDateTime(ashar, refDate),
'maghrib': timeToDateTime(maghrib, refDate),
'isya': timeToDateTime(isya, refDate),
};
}
/// Adapter for DailyPrayerSchedule.
class DailyPrayerScheduleAdapter extends TypeAdapter<DailyPrayerSchedule> {
@override
final int typeId = 1;
@override
DailyPrayerSchedule read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{};
for (int i = 0; i < numOfFields; i++) {
fields[reader.readByte()] = reader.read();
}
return DailyPrayerSchedule(
date: fields[0] as String? ?? '',
imsak: fields[1] as String? ?? '00:00',
subuh: fields[2] as String? ?? '00:00',
terbit: fields[3] as String? ?? '00:00',
dhuha: fields[4] as String? ?? '00:00',
dzuhur: fields[5] as String? ?? '00:00',
ashar: fields[6] as String? ?? '00:00',
maghrib: fields[7] as String? ?? '00:00',
isya: fields[8] as String? ?? '00:00',
);
}
@override
void write(BinaryWriter writer, DailyPrayerSchedule obj) {
writer
..writeByte(9)
..writeByte(0)..write(obj.date)
..writeByte(1)..write(obj.imsak)
..writeByte(2)..write(obj.subuh)
..writeByte(3)..write(obj.terbit)
..writeByte(4)..write(obj.dhuha)
..writeByte(5)..write(obj.dzuhur)
..writeByte(6)..write(obj.ashar)
..writeByte(7)..write(obj.maghrib)
..writeByte(8)..write(obj.isya);
}
}

View File

@@ -0,0 +1,111 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
/// Service for myQuran.com v3 Sholat API.
/// Provides Kemenag-accurate prayer times for Indonesian cities.
///
/// Ported directly from the jamshalat-diary project.
class MyQuranSholatService {
static const String _baseUrl = 'https://api.myquran.com/v3/sholat';
static final MyQuranSholatService instance = MyQuranSholatService._();
MyQuranSholatService._();
/// Search for a city/kabupaten by name.
/// Returns list of {id, lokasi}.
Future<List<Map<String, dynamic>>> searchCity(String query) async {
try {
final response = await http.get(
Uri.parse('$_baseUrl/kota/cari/$query'),
);
if (response.statusCode == 200) {
final data = json.decode(response.body);
if (data['status'] == true) {
return List<Map<String, dynamic>>.from(data['data']);
}
}
} catch (e) {
// silent fallback — device is offline
}
return [];
}
/// Get prayer times for today.
/// [cityId] = myQuran city ID (hash string)
/// Returns map: {tanggal, imsak, subuh, terbit, dhuha, dzuhur, ashar, maghrib, isya}
Future<Map<String, String>?> getDailySchedule(String cityId) async {
try {
final response = await http.get(Uri.parse('$_baseUrl/jadwal/$cityId/today'));
if (response.statusCode == 200) {
final data = json.decode(response.body);
if (data['status'] == true) {
final jadwalMap = data['data']['jadwal'] as Map<String, dynamic>;
if (jadwalMap.isNotEmpty) {
final firstKey = jadwalMap.keys.first;
final jadwal = jadwalMap[firstKey];
if (jadwal != null) {
final result = Map<String, String>.from(jadwal.map((k, v) => MapEntry(k.toString(), v.toString())));
result['date'] = firstKey;
return result;
}
}
}
}
} catch (e) {
// silent fallback
}
return null;
}
/// Get monthly prayer schedule (bulk fetch for offline caching).
/// [month] = 'yyyy-MM' format (e.g., '2024-03')
/// Returns map of date → jadwal.
Future<Map<String, Map<String, String>>> getMonthlySchedule(
String cityId, String month) async {
try {
final response = await http.get(
Uri.parse('$_baseUrl/jadwal/$cityId/$month'),
);
if (response.statusCode == 200) {
final data = json.decode(response.body);
if (data['status'] == true) {
final jadwalMap = data['data']['jadwal'] as Map<String, dynamic>;
final result = <String, Map<String, String>>{};
for (final entry in jadwalMap.entries) {
result[entry.key] = Map<String, String>.from(
(entry.value as Map).map(
(k, v) => MapEntry(k.toString(), v.toString())),
);
}
return result;
}
}
} catch (e) {
// silent fallback
}
return {};
}
/// Get city info (kabko, prov) from a jadwal response.
Future<Map<String, String>?> getCityInfo(String cityId) async {
final today =
DateTime.now().toIso8601String().substring(0, 10);
try {
final response = await http.get(
Uri.parse('$_baseUrl/jadwal/$cityId/$today'),
);
if (response.statusCode == 200) {
final data = json.decode(response.body);
if (data['status'] == true) {
return {
'kabko': data['data']['kabko']?.toString() ?? '',
'prov': data['data']['prov']?.toString() ?? '',
};
}
}
} catch (e) {
// silent fallback
}
return null;
}
}

View File

@@ -0,0 +1,46 @@
import 'package:audioplayers/audioplayers.dart';
import 'package:flutter/foundation.dart';
class SoundService {
SoundService._();
static final instance = SoundService._();
late AudioPlayer _player;
bool _initialized = false;
void init() {
if (_initialized) return;
_player = AudioPlayer();
// Pre-cache sounds by setting sources but not playing immediately if desired,
// though AudioCache is handled implicitly in newer audioplayers.
_player.setReleaseMode(ReleaseMode.stop);
_initialized = true;
}
Future<void> playAdzanBeep() async {
try {
if (!_initialized) init();
// Plays a single beep exactly when Adzan time hits
await _player.play(AssetSource('sounds/beep.mp3'));
} catch (e) {
debugPrint('[SoundService] Error playing adzan beep: $e');
}
}
Future<void> playIqomahCountdown() async {
try {
if (!_initialized) init();
// Plays the 3-beep countdown for the last 3 seconds of Iqamah
await _player.play(AssetSource('sounds/3-detik-countdown.mp3'));
} catch (e) {
debugPrint('[SoundService] Error playing iqomah countdown: $e');
}
}
void dispose() {
if (_initialized) {
_player.dispose();
_initialized = false;
}
}
}

View File

@@ -0,0 +1,93 @@
import 'package:hive_flutter/hive_flutter.dart';
import 'package:intl/intl.dart';
import '../local/models.dart';
import 'myquran_service.dart';
/// Service to sync monthly prayer data from MyQuran API → Hive.
class SyncService {
SyncService._();
static final SyncService instance = SyncService._();
/// Sync current month + next month prayer data for the configured city.
/// Returns true on success.
Future<bool> syncMonthlyData() async {
final settingsBox = Hive.box<AppSettings>(HiveBoxes.settings);
final settings = settingsBox.get('default');
if (settings == null) return false;
final cityId = settings.cityIdApi;
final now = DateTime.now();
final currentMonth = DateFormat('yyyy-MM').format(now);
// Also fetch next month for continuity
final nextMonthDate = DateTime(now.year, now.month + 1, 1);
final nextMonth = DateFormat('yyyy-MM').format(nextMonthDate);
final api = MyQuranSholatService.instance;
final scheduleBox = Hive.box<DailyPrayerSchedule>(HiveBoxes.prayerSchedule);
var success = false;
// Fetch current month
final currentData = await api.getMonthlySchedule(cityId, currentMonth);
if (currentData.isNotEmpty) {
for (final entry in currentData.entries) {
final jadwal = entry.value;
scheduleBox.put(
entry.key,
DailyPrayerSchedule(
date: entry.key,
imsak: jadwal['imsak'] ?? '00:00',
subuh: jadwal['subuh'] ?? '00:00',
terbit: jadwal['terbit'] ?? '00:00',
dhuha: jadwal['dhuha'] ?? '00:00',
dzuhur: jadwal['dzuhur'] ?? '00:00',
ashar: jadwal['ashar'] ?? '00:00',
maghrib: jadwal['maghrib'] ?? '00:00',
isya: jadwal['isya'] ?? '00:00',
),
);
}
success = true;
}
// Fetch next month
final nextData = await api.getMonthlySchedule(cityId, nextMonth);
if (nextData.isNotEmpty) {
for (final entry in nextData.entries) {
final jadwal = entry.value;
scheduleBox.put(
entry.key,
DailyPrayerSchedule(
date: entry.key,
imsak: jadwal['imsak'] ?? '00:00',
subuh: jadwal['subuh'] ?? '00:00',
terbit: jadwal['terbit'] ?? '00:00',
dhuha: jadwal['dhuha'] ?? '00:00',
dzuhur: jadwal['dzuhur'] ?? '00:00',
ashar: jadwal['ashar'] ?? '00:00',
maghrib: jadwal['maghrib'] ?? '00:00',
isya: jadwal['isya'] ?? '00:00',
),
);
}
}
if (success) {
settings.lastSyncDate = DateFormat('yyyy-MM-dd HH:mm').format(now);
await settings.save();
}
return success;
}
/// Get today's prayer schedule from local Hive cache.
DailyPrayerSchedule? getTodaySchedule([DateTime? targetDate]) {
final scheduleBox =
Hive.box<DailyPrayerSchedule>(HiveBoxes.prayerSchedule);
final dateToFetch = targetDate ?? DateTime.now();
final dateStr = DateFormat('yyyy-MM-dd').format(dateToFetch);
return scheduleBox.get(dateStr);
}
}

View File

@@ -0,0 +1,48 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
/// Service for fetching background portraits from the Unsplash API.
class UnsplashService {
static const String _clientId = 'BkgEMpfG_ReNpVwJcbgNx30IZXhoFoWwKgwbrPU0hq4';
static const String _baseUrl = 'https://api.unsplash.com';
static final UnsplashService instance = UnsplashService._();
UnsplashService._();
/// Fetches a list of highly compressed landscape URLs based on the given keyword.
Future<List<String>> fetchLandscapeBackgrounds(String keyword) async {
// Trim keyword and default to 'mosque' if empty
final query = keyword.trim().isEmpty ? 'mosque' : keyword.trim();
// Specifically requesting 'regular' size to fit 1080p elegantly while minimizing RAM overhead.
final url = Uri.parse('$_baseUrl/search/photos?query=$query&orientation=landscape&per_page=20');
try {
final response = await http.get(
url,
headers: {
'Authorization': 'Client-ID $_clientId',
'Accept-Version': 'v1',
},
);
if (response.statusCode == 200) {
final data = json.decode(response.body);
final results = data['results'] as List<dynamic>? ?? [];
final urls = <String>[];
for (final item in results) {
final urlsMap = item['urls'] as Map<String, dynamic>?;
if (urlsMap != null && urlsMap.containsKey('regular')) {
urls.add(urlsMap['regular'].toString());
}
}
return urls;
}
} catch (e) {
// Offline or error — fail silently.
}
return [];
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,330 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../core/sacred_tokens.dart';
import '../../providers.dart';
/// Full-screen Adzan alert with pulsing icon and glowing text.
class AdzanAlertScreen extends ConsumerWidget {
const AdzanAlertScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final screenData = ref.watch(screenStateProvider);
final clock = ref.watch(clockProvider).valueOrNull ?? DateTime.now();
final schedule = ref.watch(todayScheduleProvider);
final settings = ref.watch(settingsProvider);
final size = MediaQuery.of(context).size;
final s = size.width / 1920;
final prayerLabel = screenData.activePrayer
?.displayLabel(isFriday: screenData.isFriday) ??
'';
final timeStr =
'${clock.hour.toString().padLeft(2, '0')}:${clock.minute.toString().padLeft(2, '0')}';
final secStr = clock.second.toString().padLeft(2, '0');
final fs = s * ref.watch(textScaleProvider);
return Container(
color: SacredColors.background,
child: Stack(
children: [
// Background gradient
Positioned.fill(
child: Container(
decoration: BoxDecoration(
gradient: RadialGradient(
center: Alignment.center,
radius: 0.8,
colors: [
SacredColors.background,
SacredColors.background,
],
),
),
),
),
// Ghost mosque icon
Positioned(
left: 40 * s,
top: 0,
bottom: 0,
child: Center(
child: Opacity(
opacity: 0.03,
child: Icon(Icons.mosque, size: 500 * s,
color: SacredColors.onSurface),
),
),
),
// ── Header ──
Positioned(
top: 0,
left: 0,
right: 0,
child: Container(
padding: EdgeInsets.symmetric(
horizontal: 64 * s, vertical: 24 * s),
color: SacredColors.background,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
settings.masjidName,
style: GoogleFonts.plusJakartaSans(
fontSize: 32 * s,
fontWeight: FontWeight.w700,
color: SacredColors.primary,
),
),
Text(
settings.masjidAddress,
style: GoogleFonts.manrope(
fontSize: 14 * fs,
fontWeight: FontWeight.w500,
color: SacredColors.secondary,
),
),
],
),
],
),
),
),
// ── Central Alert Content ──
Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Pulsing bell icon with glow
_PulsingIcon(scale: s),
SizedBox(height: 40 * s),
// "WAKTU ADZAN [PRAYER]"
Text(
'WAKTU ADZAN $prayerLabel',
style: GoogleFonts.plusJakartaSans(
fontSize: 80 * s,
fontWeight: FontWeight.w800,
color: SacredColors.secondary,
letterSpacing: -2 * s,
shadows: [
Shadow(
blurRadius: 40 * s,
color: SacredColors.secondary.withValues(alpha: 0.4),
),
],
),
),
SizedBox(height: 32 * s),
// Clock in pill
Container(
padding: EdgeInsets.symmetric(
horizontal: 48 * s, vertical: 20 * s),
decoration: BoxDecoration(
color: SacredColors.surfaceContainerHighest
.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(SacredRadii.full),
border: Border.all(
color: SacredColors.outlineVariant.withValues(alpha: 0.15),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
timeStr,
style: GoogleFonts.plusJakartaSans(
fontSize: 120 * s,
fontWeight: FontWeight.w700,
color: SacredColors.primary,
letterSpacing: -3 * s,
height: 1.0,
shadows: [
Shadow(
blurRadius: 30 * s,
color:
SacredColors.primary.withValues(alpha: 0.3),
),
],
),
),
SizedBox(width: 12 * s),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
secStr,
style: GoogleFonts.plusJakartaSans(
fontSize: 48 * s,
fontWeight: FontWeight.w700,
color: SacredColors.onSurface
.withValues(alpha: 0.5),
),
),
Text(
'WIB',
style: GoogleFonts.manrope(
fontSize: 20 * s,
fontWeight: FontWeight.w500,
color: SacredColors.primary,
letterSpacing: 4 * s,
),
),
],
),
],
),
),
SizedBox(height: 40 * s),
// Sub-message
Text(
'Telah masuk waktu shalat $prayerLabel.\nSegera persiapkan diri menuju masjid.',
textAlign: TextAlign.center,
style: GoogleFonts.manrope(
fontSize: 24 * fs,
fontWeight: FontWeight.w500,
color: SacredColors.onSurface.withValues(alpha: 0.8),
height: 1.5,
),
),
],
),
),
// ── Footer: Prayer times strip ──
if (schedule != null)
Positioned(
left: 0,
right: 0,
bottom: 0,
child: _buildFooterStrip(s, fs, schedule, screenData),
),
],
),
);
}
Widget _buildFooterStrip(
double s, double fs, dynamic schedule, dynamic screenData) {
final prayers = {
'Subuh': schedule.subuh,
'Dzuhur': schedule.dzuhur,
'Ashar': schedule.ashar,
'Maghrib': schedule.maghrib,
'Isya': schedule.isya,
};
return Container(
padding: EdgeInsets.symmetric(horizontal: 64 * s, vertical: 28 * s),
color: SacredColors.surfaceContainerLow.withValues(alpha: 0.8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: prayers.entries.map((e) {
final isActive = screenData.activePrayer?.id == e.key.toLowerCase();
return Column(
children: [
Text(
e.key.toUpperCase(),
style: GoogleFonts.manrope(
fontSize: 12 * fs,
fontWeight: FontWeight.w700,
color: isActive
? SacredColors.primary
: SacredColors.onSurface.withValues(alpha: 0.4),
letterSpacing: 2 * s,
),
),
SizedBox(height: 4 * s),
Text(
e.value,
style: GoogleFonts.plusJakartaSans(
fontSize: isActive ? 32 * fs : 28 * fs,
fontWeight: isActive ? FontWeight.w700 : FontWeight.w600,
color: isActive
? SacredColors.primary
: SacredColors.onSurface,
),
),
],
);
}).toList(),
),
);
}
}
class _PulsingIcon extends StatefulWidget {
final double scale;
const _PulsingIcon({required this.scale});
@override
State<_PulsingIcon> createState() => _PulsingIconState();
}
class _PulsingIconState extends State<_PulsingIcon>
with SingleTickerProviderStateMixin {
late AnimationController _ctrl;
@override
void initState() {
super.initState();
_ctrl = AnimationController(
vsync: this,
duration: const Duration(seconds: 2),
)..repeat(reverse: true);
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final s = widget.scale;
return AnimatedBuilder(
animation: _ctrl,
builder: (context, child) {
return Stack(
alignment: Alignment.center,
children: [
Container(
width: 200 * s,
height: 200 * s,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: SacredColors.secondary
.withValues(alpha: 0.1 * _ctrl.value),
boxShadow: [
BoxShadow(
blurRadius: 60 * s * _ctrl.value,
color: SacredColors.secondary.withValues(alpha: 0.1),
),
],
),
),
Icon(
Icons.notifications_active,
size: 120 * s,
color: SacredColors.secondary,
),
],
);
},
);
}
}

View File

@@ -0,0 +1,108 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../core/sacred_tokens.dart';
import '../../providers.dart';
/// Minimal black screen during prayer — absolute zero distraction.
class BlackScreen extends ConsumerWidget {
const BlackScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final screenData = ref.watch(screenStateProvider);
final size = MediaQuery.of(context).size;
final s = size.width / 1920;
final prayerLabel = screenData.activePrayer
?.displayLabel(isFriday: screenData.isFriday) ??
'';
return Container(
color: SacredColors.blackScreen,
child: Stack(
children: [
// Extremely subtle radial tonal shift
Positioned.fill(
child: Container(
decoration: BoxDecoration(
gradient: RadialGradient(
center: Alignment.center,
radius: 1.2,
colors: [
SacredColors.primaryContainer.withValues(alpha: 0.03),
SacredColors.blackScreen,
],
),
),
),
),
// Center: prayer name + status (extremely dim)
Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Opacity(
opacity: 0.10,
child: Text(
prayerLabel.toUpperCase(),
style: GoogleFonts.plusJakartaSans(
fontSize: 96 * s,
fontWeight: FontWeight.w300,
color: SacredColors.onSurface,
letterSpacing: 20 * s,
),
),
),
SizedBox(height: 16 * s),
Opacity(
opacity: 0.08,
child: Text(
'SHALAT SEDANG BERLANGSUNG',
style: GoogleFonts.plusJakartaSans(
fontSize: 14 * s,
fontWeight: FontWeight.w500,
color: SacredColors.onSurface,
letterSpacing: 6 * s,
),
),
),
],
),
),
// Bottom advisory — barely visible
Positioned(
bottom: 64 * s,
left: 0,
right: 0,
child: Center(
child: Opacity(
opacity: 0.06,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.phonelink_ring,
size: 14 * s, color: SacredColors.onSurface),
SizedBox(width: 8 * s),
Text(
'MOHON NONAKTIFKAN ALAT KOMUNIKASI',
style: GoogleFonts.manrope(
fontSize: 10 * s,
fontWeight: FontWeight.w500,
color: SacredColors.onSurface,
letterSpacing: 3 * s,
),
),
],
),
),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,145 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../data/services/sync_service.dart';
import '../../data/services/sound_service.dart';
import '../../core/enums.dart';
import '../../core/sacred_tokens.dart';
import '../../providers.dart';
import 'main_screen.dart';
import 'adzan_screen.dart';
import 'iqomah_screen.dart';
import 'black_screen.dart';
import 'slideshow_screen.dart';
import 'jumat_screen.dart';
import 'khutbah_screen.dart';
/// The root view that orchestrates all screen states via AnimatedSwitcher.
class HomeView extends ConsumerStatefulWidget {
const HomeView({super.key});
@override
ConsumerState<HomeView> createState() => _HomeViewState();
}
class _HomeViewState extends ConsumerState<HomeView> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_checkAutoSync();
});
}
Future<void> _checkAutoSync() async {
final schedule = ref.read(todayScheduleProvider);
if (schedule == null) {
debugPrint('[AutoSync] No schedule found for today! Starting auto-sync...');
final success = await SyncService.instance.syncMonthlyData();
if (success && mounted) {
debugPrint('[AutoSync] Success! Invalidating todayScheduleProvider.');
ref.invalidate(todayScheduleProvider);
} else {
debugPrint('[AutoSync] Failed or data remained empty.');
}
}
}
@override
Widget build(BuildContext context) {
// Audio trigger listener
ref.listen<ScreenStateData>(screenStateProvider, (previous, next) {
if (previous == null) return;
// TRIGGER 1: Adzan Beep (Fires precisely when transitioning to Adzan)
if (previous.state != ScreenState.adzan && next.state == ScreenState.adzan) {
SoundService.instance.playAdzanBeep();
}
// TRIGGER 2: 3-Second Iqomah Countdown
if (next.state == ScreenState.menujuIqomah && next.iqomahRemaining != null) {
// Play precisely on the tick where it is 3 seconds.
if (previous.iqomahRemaining?.inSeconds != 3 && next.iqomahRemaining!.inSeconds == 3) {
SoundService.instance.playIqomahCountdown();
}
}
});
final screenData = ref.watch(screenStateProvider);
final isMainScreen = ref.watch(isMainScreenProvider);
// Determine which screen to display
Widget screen;
switch (screenData.state) {
case ScreenState.normal:
case ScreenState.menujuAdzan:
if (screenData.isFriday && screenData.nextPrayer?.id == 'dzuhur') {
screen = const JumatScreen(key: ValueKey('jumat'));
} else {
screen = isMainScreen
? const MainScreen(key: ValueKey('main'))
: const SlideshowScreen(key: ValueKey('slideshow'));
}
break;
case ScreenState.kembaliNormal:
screen = const MainScreen(key: ValueKey('main'));
break;
case ScreenState.adzan:
screen = const AdzanAlertScreen(key: ValueKey('adzan'));
break;
case ScreenState.menujuIqomah:
if (screenData.isFriday && screenData.activePrayer?.id == 'dzuhur') {
screen = const KhutbahScreen(key: ValueKey('khutbah'));
} else {
screen = const IqomahScreen(key: ValueKey('iqomah'));
}
break;
case ScreenState.shalat:
screen = const BlackScreen(key: ValueKey('black'));
break;
}
final isSimulating = ref.watch(mockTimeOffsetProvider) != Duration.zero;
return Scaffold(
backgroundColor: SacredColors.background,
body: Stack(
children: [
AnimatedSwitcher(
duration: const Duration(milliseconds: 800),
transitionBuilder: (child, animation) {
return FadeTransition(opacity: animation, child: child);
},
child: screen,
),
if (isSimulating)
Positioned(
right: 64,
bottom: 64,
child: ElevatedButton.icon(
onPressed: () {
ref.read(mockTimeOffsetProvider.notifier).state = Duration.zero;
},
icon: const Icon(Icons.cancel, color: Colors.white),
label: const Text(
'BATALKAN SIMULASI',
style: TextStyle(
fontWeight: FontWeight.bold,
letterSpacing: 2,
color: Colors.white,
),
),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red.shade800,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
elevation: 10,
),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,367 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../core/sacred_tokens.dart';
import '../../providers.dart';
/// FORMAT HELPER: Duration → "MM:SS"
String _fmtCountdown(Duration d) {
final m = d.inMinutes.toString().padLeft(2, '0');
final s = (d.inSeconds % 60).toString().padLeft(2, '0');
return '$m:$s';
}
/// Iqomah countdown screen — and Friday Khutbah info override.
class IqomahScreen extends ConsumerWidget {
const IqomahScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final screenData = ref.watch(screenStateProvider);
final settings = ref.watch(settingsProvider);
final size = MediaQuery.of(context).size;
final s = size.width / 1920;
final prayerLabel = screenData.activePrayer
?.displayLabel(isFriday: screenData.isFriday) ??
'';
final countdown = screenData.iqomahRemaining ?? Duration.zero;
final isFridayDzuhur =
screenData.isFriday && screenData.activePrayer?.id == 'dzuhur';
return Container(
color: SacredColors.background,
child: Stack(
children: [
// Subtle radial glow
Positioned.fill(
child: Container(
decoration: BoxDecoration(
gradient: RadialGradient(
center: Alignment.center,
radius: 0.7,
colors: [
SacredColors.primary.withValues(alpha: 0.08),
SacredColors.background,
],
),
),
),
),
// ── Content ──
Padding(
padding: EdgeInsets.all(64 * s),
child: Column(
children: [
// Header
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
settings.masjidName.toUpperCase(),
style: GoogleFonts.plusJakartaSans(
fontSize: 36 * s,
fontWeight: FontWeight.w800,
color: SacredColors.primary,
letterSpacing: -1 * s,
),
),
Text(
settings.masjidAddress,
style: GoogleFonts.manrope(
fontSize: 12 * s,
color: SacredColors.onSurface.withValues(alpha: 0.6),
letterSpacing: 4 * s,
),
),
],
),
],
),
// ── Center: Countdown ──
Expanded(
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Prayer name pill
Text(
'SHALAT SAAT INI',
style: GoogleFonts.manrope(
fontSize: 16 * s,
fontWeight: FontWeight.w500,
color: SacredColors.onSurface.withValues(alpha: 0.5),
letterSpacing: 8 * s,
),
),
SizedBox(height: 12 * s),
Container(
padding: EdgeInsets.symmetric(
horizontal: 48 * s, vertical: 16 * s),
decoration: BoxDecoration(
color: SacredColors.surfaceContainerHighest,
borderRadius:
BorderRadius.circular(SacredRadii.full),
border: Border.all(
color: SacredColors.primary.withValues(alpha: 0.1),
),
),
child: Text(
prayerLabel.toUpperCase(),
style: GoogleFonts.plusJakartaSans(
fontSize: 56 * s,
fontWeight: FontWeight.w800,
color: SacredColors.primary,
letterSpacing: 2 * s,
),
),
),
SizedBox(height: 32 * s),
// Title
Text(
isFridayDzuhur
? 'PERSIAPAN KHUTBAH'
: 'MENUJU IQOMAH',
style: GoogleFonts.plusJakartaSans(
fontSize: 36 * s,
fontWeight: FontWeight.w700,
color: SacredColors.secondary,
letterSpacing: 4 * s,
),
),
SizedBox(height: 16 * s),
// Giant timer
Text(
_fmtCountdown(countdown),
style: GoogleFonts.plusJakartaSans(
fontSize: 280 * s,
fontWeight: FontWeight.w800,
color: SacredColors.onSurface,
letterSpacing: -8 * s,
height: 1.0,
shadows: [
Shadow(
blurRadius: 40 * s,
color:
SacredColors.primary.withValues(alpha: 0.3),
),
],
),
),
// Pulsing status pill
_StatusPill(
label: 'SIAPKAN DIRI ANDA',
scale: s,
),
// Friday officers info
if (isFridayDzuhur) ...[
SizedBox(height: 32 * s),
_FridayOfficers(settings: settings, scale: s),
],
],
),
),
),
// Hadith reminder
Container(
padding: EdgeInsets.all(32 * s),
decoration: BoxDecoration(
color: SacredColors.surfaceContainerLow.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(SacredRadii.xl),
border: Border(
left: BorderSide(
color: SacredColors.secondary, width: 4 * s),
),
),
child: Text(
'"Luruskan dan Rapatkan Shaf, Sesungguhnya lurusnya shaf termasuk kesempurnaan shalat."',
style: GoogleFonts.plusJakartaSans(
fontSize: 28 * s,
fontWeight: FontWeight.w500,
color: SacredColors.onSurface,
fontStyle: FontStyle.italic,
height: 1.5,
),
),
),
],
),
),
],
),
);
}
}
class _StatusPill extends StatefulWidget {
final String label;
final double scale;
const _StatusPill({required this.label, required this.scale});
@override
State<_StatusPill> createState() => _StatusPillState();
}
class _StatusPillState extends State<_StatusPill>
with SingleTickerProviderStateMixin {
late AnimationController _ctrl;
@override
void initState() {
super.initState();
_ctrl = AnimationController(
vsync: this, duration: const Duration(seconds: 2))
..repeat(reverse: true);
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final s = widget.scale;
return FadeTransition(
opacity: Tween(begin: 0.5, end: 1.0).animate(_ctrl),
child: Container(
margin: EdgeInsets.only(top: 16 * s),
padding:
EdgeInsets.symmetric(horizontal: 32 * s, vertical: 12 * s),
decoration: BoxDecoration(
color: SacredColors.secondary.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(SacredRadii.full),
border: Border.all(
color: SacredColors.secondary.withValues(alpha: 0.2),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.notifications_active,
color: SacredColors.secondary, size: 20 * s),
SizedBox(width: 8 * s),
Text(
widget.label,
style: GoogleFonts.plusJakartaSans(
fontSize: 16 * s,
fontWeight: FontWeight.w700,
color: SacredColors.secondary,
letterSpacing: 3 * s,
),
),
],
),
),
);
}
}
class _FridayOfficers extends StatelessWidget {
final dynamic settings;
final double scale;
const _FridayOfficers({required this.settings, required this.scale});
@override
Widget build(BuildContext context) {
final s = scale;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
_OfficerCard(
icon: Icons.person_pin,
label: 'KHATIB',
name: settings.khatibName,
color: SacredColors.primary,
scale: s,
),
SizedBox(width: 24 * s),
_OfficerCard(
icon: Icons.timer,
label: 'IMAM',
name: settings.imamName,
color: SacredColors.secondary,
scale: s,
),
],
);
}
}
class _OfficerCard extends StatelessWidget {
final IconData icon;
final String label;
final String name;
final Color color;
final double scale;
const _OfficerCard({
required this.icon,
required this.label,
required this.name,
required this.color,
required this.scale,
});
@override
Widget build(BuildContext context) {
final s = scale;
return Container(
padding: EdgeInsets.all(24 * s),
decoration: BoxDecoration(
color: SacredColors.surfaceContainerHigh.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(SacredRadii.xl),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: EdgeInsets.all(12 * s),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(SacredRadii.lg),
),
child: Icon(icon, color: color, size: 24 * s),
),
SizedBox(width: 16 * s),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: GoogleFonts.manrope(
fontSize: 10 * s,
fontWeight: FontWeight.w700,
color: SacredColors.onSurface.withValues(alpha: 0.4),
letterSpacing: 3 * s,
),
),
Text(
name,
style: GoogleFonts.plusJakartaSans(
fontSize: 20 * s,
fontWeight: FontWeight.w700,
color: SacredColors.onSurface,
),
),
],
),
],
),
);
}
}

View File

@@ -0,0 +1,530 @@
import 'dart:io';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:hugeicons/hugeicons.dart';
import '../../core/hijri_date.dart';
import '../../core/sacred_tokens.dart';
import '../../providers.dart';
import '../../data/local/models.dart';
import '../admin/admin_screen.dart';
import 'unsplash_background.dart';
/// A highly polished, dedicated screen displayed specifically on Fridays
/// when transitioning towards Dzuhur (Jumat) prayer.
class JumatScreen extends ConsumerWidget {
const JumatScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final clock = ref.watch(clockProvider).valueOrNull ?? DateTime.now();
final schedule = ref.watch(todayScheduleProvider);
final settings = ref.watch(settingsProvider);
final screenData = ref.watch(screenStateProvider);
final size = MediaQuery.of(context).size;
final s = size.width / 1920;
final fs = s * ref.watch(textScaleProvider);
final timeStr = DateFormat('HH:mm').format(clock);
final secStr = DateFormat(':ss').format(clock);
final dateGregorian = DateFormat('EEEE, d MMMM yyyy', 'en').format(clock);
final dateHijri = HijriDateFormatter.format(clock);
final durToKhutbah = screenData.timeUntilNext ?? const Duration(minutes: 0);
final minToKhutbah = durToKhutbah.inMinutes;
return Container(
color: SacredColors.background,
child: Stack(
children: [
// ── Underlay: Branded local image or Unsplash ──
if (settings.brandedBgImage != null && settings.brandedBgImage!.isNotEmpty)
Positioned.fill(
child: Image.file(
File(settings.brandedBgImage!),
fit: BoxFit.cover,
color: Colors.black.withValues(alpha: 0.55),
colorBlendMode: BlendMode.darken,
),
)
else
const UnsplashBackground(),
// ── Background darkness gradient ──
Positioned.fill(
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [
SacredColors.background.withValues(alpha: 0.8),
Colors.transparent,
SacredColors.background.withValues(alpha: 0.9),
],
stops: const [0.0, 0.4, 1.0],
),
),
),
),
// ── Header Shell ──
Positioned(
top: 48 * s,
left: 64 * s,
right: 64 * s,
child: _buildHeader(context, settings, s),
),
// ── Main Content Canvas ──
Positioned.fill(
top: 140 * s,
bottom: 240 * s,
left: 64 * s,
right: 64 * s,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// ── Left Column: Clock & Primary Focus ──
Expanded(
flex: 2,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Pill
Container(
padding: EdgeInsets.symmetric(horizontal: 24 * s, vertical: 8 * s),
decoration: BoxDecoration(
color: SacredColors.secondary.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(SacredRadii.full),
border: Border.all(color: SacredColors.secondary.withValues(alpha: 0.2)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_PulsingDot(color: SacredColors.secondary, size: 12 * s),
SizedBox(width: 12 * s),
Text(
'PERSIAPAN JUMAT',
style: GoogleFonts.plusJakartaSans(
fontSize: 16 * s,
fontWeight: FontWeight.w700,
color: SacredColors.secondary,
letterSpacing: 3 * s,
),
),
],
),
),
SizedBox(height: 16 * s),
// Massive Clock
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
timeStr,
style: GoogleFonts.plusJakartaSans(
fontSize: 192 * s,
fontWeight: FontWeight.w800,
color: SacredColors.primary,
letterSpacing: -5 * s,
height: 1.0,
),
),
Padding(
padding: EdgeInsets.only(top: 24 * s),
child: Text(
secStr,
style: GoogleFonts.plusJakartaSans(
fontSize: 48 * s,
fontWeight: FontWeight.w700,
color: SacredColors.primary.withValues(alpha: 0.7),
),
),
),
],
),
SizedBox(height: 16 * s),
// Dates
Text(
dateGregorian,
style: GoogleFonts.plusJakartaSans(
fontSize: 36 * s,
fontWeight: FontWeight.w700,
color: SacredColors.onSurface,
),
),
SizedBox(height: 4 * s),
Text(
dateHijri,
style: GoogleFonts.manrope(
fontSize: 24 * s,
fontWeight: FontWeight.w500,
color: SacredColors.secondary.withValues(alpha: 0.9),
),
),
],
),
),
// ── Right Column: Khutbah Info Card ──
Expanded(
flex: 1,
child: Container(
padding: EdgeInsets.all(40 * s),
decoration: BoxDecoration(
color: SacredColors.surfaceContainerHigh.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(SacredRadii.xl),
border: Border.all(color: Colors.white.withValues(alpha: 0.05)),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(SacredRadii.xl),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'NEXT EVENT',
style: GoogleFonts.manrope(
fontSize: 14 * s,
fontWeight: FontWeight.w600,
color: SacredColors.onSurfaceVariant,
letterSpacing: 3 * s,
),
),
HugeIcon(icon: HugeIcons.strokeRoundedSparkles, color: SacredColors.secondary, size: 24 * s),
],
),
SizedBox(height: 16 * s),
Text(
'MENUJU KHUTBAH',
style: GoogleFonts.plusJakartaSans(
fontSize: 42 * s,
fontWeight: FontWeight.w800,
color: SacredColors.onSurface,
height: 1.1,
),
),
SizedBox(height: 32 * s),
// Khatib Info
_buildInfoTile(
s,
icon: Icons.person_pin,
color: SacredColors.primary,
label: 'KHATIB HARI INI',
value: settings.khatibName.isEmpty ? 'Belum Diatur' : settings.khatibName
),
SizedBox(height: 24 * s),
// Countdown Info
_buildInfoTile(
s,
icon: Icons.timer,
color: SacredColors.secondary,
label: 'KHUTBAH DIMULAI DALAM',
value: minToKhutbah > 0 ? '~ $minToKhutbah Menit' : 'Sebentar Lagi'
),
],
),
),
),
),
),
],
),
),
// ── Bottom Navigation Shell (Prayer Times) ──
if (schedule != null)
Positioned(
left: 64 * s,
right: 64 * s,
bottom: 64 * s,
child: _buildPrayerTimesRow(s, schedule),
),
// ── Footer Marquee Shell ──
Positioned(
left: 0,
right: 0,
bottom: 0,
child: _buildMarquee(s, fs, settings),
),
],
),
);
}
Widget _buildHeader(BuildContext context, AppSettings settings, double s) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// Mosque Name
GestureDetector(
onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (_) => const AdminScreen())),
child: Text(
settings.masjidName,
style: GoogleFonts.plusJakartaSans(
fontSize: 32 * s,
fontWeight: FontWeight.w800,
color: SacredColors.primary,
),
),
),
// Mosque Address
Row(
children: [
HugeIcon(icon: HugeIcons.strokeRoundedMosque01, color: SacredColors.primary, size: 24 * s),
SizedBox(width: 8 * s),
Text(
settings.masjidAddress,
style: GoogleFonts.manrope(
fontSize: 18 * s,
fontWeight: FontWeight.w500,
color: SacredColors.onSurfaceVariant,
),
),
],
),
],
);
}
Widget _buildInfoTile(double s, {required IconData icon, required Color color, required String label, required String value}) {
return Container(
padding: EdgeInsets.all(20 * s),
decoration: BoxDecoration(
color: SacredColors.surfaceContainerHighest.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(SacredRadii.xl),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: EdgeInsets.all(12 * s),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(SacredRadii.md),
),
child: Icon(icon, color: color, size: 24 * s),
),
SizedBox(width: 16 * s),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: GoogleFonts.manrope(
fontSize: 12 * s,
fontWeight: FontWeight.w700,
color: SacredColors.onSurfaceVariant,
letterSpacing: 2 * s,
),
),
SizedBox(height: 4 * s),
Text(
value,
style: GoogleFonts.plusJakartaSans(
fontSize: 22 * s,
fontWeight: FontWeight.w700,
color: SacredColors.onSurface,
),
),
],
),
),
],
),
);
}
Widget _buildPrayerTimesRow(double s, DailyPrayerSchedule schedule) {
return Container(
padding: EdgeInsets.symmetric(horizontal: 16 * s, vertical: 16 * s),
decoration: BoxDecoration(
color: SacredColors.surfaceContainerLow,
borderRadius: BorderRadius.circular(SacredRadii.xl),
boxShadow: [
BoxShadow(
color: SacredColors.primary.withValues(alpha: 0.1),
blurRadius: 40 * s,
spreadRadius: 0,
),
],
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildTimeItem(s, 'Fajr', schedule.subuh, Icons.brightness_3, false),
_buildTimeItem(s, 'Terbit', schedule.terbit, Icons.wb_twilight, false),
_buildTimeItem(s, 'JUMAT', schedule.dzuhur, Icons.wb_sunny, true),
_buildTimeItem(s, 'Asr', schedule.ashar, Icons.sunny_snowing, false),
_buildTimeItem(s, 'Maghrib', schedule.maghrib, Icons.wb_cloudy, false),
_buildTimeItem(s, 'Isha', schedule.isya, Icons.bedtime, false),
],
),
);
}
Widget _buildTimeItem(double s, String name, String time, IconData icon, bool isJumat) {
if (isJumat) {
return Container(
padding: EdgeInsets.symmetric(horizontal: 48 * s, vertical: 12 * s),
decoration: BoxDecoration(
color: SacredColors.primary.withValues(alpha: 0.15),
border: Border.all(color: SacredColors.primary.withValues(alpha: 0.3)),
borderRadius: BorderRadius.circular(SacredRadii.xl),
),
child: Column(
children: [
Icon(icon, color: SacredColors.primary, size: 28 * s),
SizedBox(height: 8 * s),
Text(name, style: GoogleFonts.manrope(fontSize: 18 * s, fontWeight: FontWeight.w800, color: SacredColors.primary)),
SizedBox(height: 4 * s),
Text(time, style: GoogleFonts.plusJakartaSans(fontSize: 16 * s, fontWeight: FontWeight.w700, color: SacredColors.primary)),
],
),
);
}
return Padding(
padding: EdgeInsets.symmetric(horizontal: 24 * s, vertical: 8 * s),
child: Column(
children: [
Icon(icon, color: SacredColors.onSurface.withValues(alpha: 0.6), size: 24 * s),
SizedBox(height: 8 * s),
Text(name, style: GoogleFonts.manrope(fontSize: 16 * s, fontWeight: FontWeight.w500, color: SacredColors.onSurface.withValues(alpha: 0.6))),
SizedBox(height: 4 * s),
Text(time, style: GoogleFonts.plusJakartaSans(fontSize: 16 * s, fontWeight: FontWeight.w700, color: SacredColors.onSurface)),
],
),
);
}
Widget _buildMarquee(double s, double fs, AppSettings settings) {
// Quick custom simplified marquee or fallback to settings.runningTexts
final texts = settings.runningTexts.isEmpty
? ["JUMAT MUBARAK: Luruskan dan rapatkan shaf. Harap non-aktifkan alat komunikasi."]
: settings.runningTexts;
return Container(
width: double.infinity,
height: 44 * s,
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.4),
),
child: ClipRect(
child: _JumatMarquee(
texts: texts,
s: s,
),
),
);
}
}
class _PulsingDot extends StatefulWidget {
final Color color;
final double size;
const _PulsingDot({required this.color, required this.size});
@override
State<_PulsingDot> createState() => _PulsingDotState();
}
class _PulsingDotState extends State<_PulsingDot> with SingleTickerProviderStateMixin {
late AnimationController _ctrl;
@override
void initState() {
super.initState();
_ctrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 1200))..repeat();
}
@override
void dispose() { _ctrl.dispose(); super.dispose(); }
@override
Widget build(BuildContext context) {
return SizedBox(
width: widget.size, height: widget.size,
child: Stack(
children: [
FadeTransition(
opacity: Tween(begin: 0.75, end: 0.0).animate(_ctrl),
child: ScaleTransition(
scale: Tween(begin: 1.0, end: 2.0).animate(_ctrl),
child: Container(decoration: BoxDecoration(shape: BoxShape.circle, color: widget.color)),
),
),
Center(child: Container(width: widget.size, height: widget.size, decoration: BoxDecoration(shape: BoxShape.circle, color: widget.color))),
],
),
);
}
}
class _JumatMarquee extends StatefulWidget {
final List<String> texts;
final double s;
const _JumatMarquee({required this.texts, required this.s});
@override
State<_JumatMarquee> createState() => _JumatMarqueeState();
}
class _JumatMarqueeState extends State<_JumatMarquee> with TickerProviderStateMixin {
late AnimationController _ctrl;
@override
void initState() {
super.initState();
_ctrl = AnimationController(vsync: this, duration: const Duration(seconds: 30))..repeat();
}
@override
void dispose() { _ctrl.dispose(); super.dispose(); }
@override
Widget build(BuildContext context) {
final joined = widget.texts.join("");
final style = GoogleFonts.manrope(
fontSize: 16 * widget.s,
fontWeight: FontWeight.w600,
color: SacredColors.secondary,
letterSpacing: 2 * widget.s,
);
return LayoutBuilder(
builder: (context, constraints) {
return Stack(
children: [
AnimatedBuilder(
animation: _ctrl,
builder: (ctx, child) {
// Ensure endless scroll mathematically
return Positioned(
left: -(_ctrl.value * constraints.maxWidth),
top: 0, bottom: 0,
child: Row(
children: [
Container(alignment: Alignment.centerLeft, width: constraints.maxWidth, child: Text(joined, style: style, maxLines: 1)),
Container(alignment: Alignment.centerLeft, width: constraints.maxWidth, child: Text(joined, style: style, maxLines: 1)),
],
),
);
},
),
],
);
}
);
}
}

View File

@@ -0,0 +1,202 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../core/sacred_tokens.dart';
import '../../providers.dart';
/// Screen displayed uniquely during the Friday Khutbah.
/// It acts like a black (shalat) screen to avoid distraction,
/// but features the active Khatib and Muadzin information and a pulsing indicator.
/// NO COUNTDOWNS are displayed.
class KhutbahScreen extends ConsumerWidget {
const KhutbahScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(settingsProvider);
final size = MediaQuery.of(context).size;
final s = size.width / 1920;
return Container(
color: Colors.black,
child: Stack(
children: [
// Absolute center elements
Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Animated Pulsing Status Pill
Container(
padding: EdgeInsets.symmetric(horizontal: 40 * s, vertical: 16 * s),
decoration: BoxDecoration(
color: SacredColors.background,
borderRadius: BorderRadius.circular(SacredRadii.full),
border: Border.all(color: SacredColors.outlineVariant.withValues(alpha: 0.3)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_PulsingMic(size: 24 * s, color: SacredColors.secondary),
SizedBox(width: 16 * s),
Text(
'SEDANG KHUTBAH JUMAT',
style: GoogleFonts.plusJakartaSans(
fontSize: 24 * s,
fontWeight: FontWeight.w800,
color: SacredColors.secondary,
letterSpacing: 4 * s,
),
),
],
),
),
SizedBox(height: 64 * s),
// Officer Info Cards
Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildKhutbahOfficerCard(
s: s,
icon: Icons.person_pin,
label: 'KHATIB',
name: settings.khatibName.isEmpty ? 'Khatib Hari Ini' : settings.khatibName,
color: SacredColors.primary,
),
SizedBox(width: 48 * s),
_buildKhutbahOfficerCard(
s: s,
icon: Icons.record_voice_over,
label: 'MUADZIN / IMAM',
name: settings.imamName.isEmpty ? 'Muadzin Hari Ini' : settings.imamName,
color: SacredColors.secondary,
),
],
),
SizedBox(height: 80 * s),
Text(
'"Harap menjaga ketenangan dan menyimak Khutbah."',
style: GoogleFonts.manrope(
fontSize: 20 * s,
fontWeight: FontWeight.w500,
color: SacredColors.onSurfaceVariant.withValues(alpha: 0.5),
fontStyle: FontStyle.italic,
),
)
],
),
),
// Simple corner logo/name so it doesn't feel entirely dead
Positioned(
top: 48 * s,
left: 48 * s,
child: Row(
children: [
Icon(Icons.mosque, color: SacredColors.onSurfaceVariant.withValues(alpha: 0.3), size: 24 * s),
SizedBox(width: 12 * s),
Text(
settings.masjidName,
style: GoogleFonts.plusJakartaSans(
fontSize: 16 * s,
fontWeight: FontWeight.w700,
color: SacredColors.onSurfaceVariant.withValues(alpha: 0.3),
letterSpacing: 2 * s,
),
),
],
),
)
],
),
);
}
Widget _buildKhutbahOfficerCard({
required double s,
required IconData icon,
required String label,
required String name,
required Color color,
}) {
return Container(
width: 400 * s,
padding: EdgeInsets.all(32 * s),
decoration: BoxDecoration(
color: SacredColors.surfaceContainerLow.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(SacredRadii.xl),
border: Border.all(color: color.withValues(alpha: 0.1)),
),
child: Column(
children: [
Container(
padding: EdgeInsets.all(20 * s),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: Icon(icon, color: color, size: 40 * s),
),
SizedBox(height: 24 * s),
Text(
label,
style: GoogleFonts.manrope(
fontSize: 14 * s,
fontWeight: FontWeight.w700,
color: SacredColors.onSurfaceVariant,
letterSpacing: 4 * s,
),
),
SizedBox(height: 8 * s),
Text(
name,
textAlign: TextAlign.center,
style: GoogleFonts.plusJakartaSans(
fontSize: 28 * s,
fontWeight: FontWeight.w800,
color: SacredColors.onSurface,
),
),
],
),
);
}
}
class _PulsingMic extends StatefulWidget {
final double size;
final Color color;
const _PulsingMic({required this.size, required this.color});
@override
State<_PulsingMic> createState() => _PulsingMicState();
}
class _PulsingMicState extends State<_PulsingMic> with SingleTickerProviderStateMixin {
late AnimationController _ctrl;
@override
void initState() {
super.initState();
_ctrl = AnimationController(vsync: this, duration: const Duration(seconds: 2))..repeat(reverse: true);
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return FadeTransition(
opacity: Tween(begin: 0.3, end: 1.0).animate(_ctrl),
child: Icon(Icons.mic, size: widget.size, color: widget.color),
);
}
}

View File

@@ -0,0 +1,791 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:hugeicons/hugeicons.dart';
import '../../core/hijri_date.dart';
import '../../core/sacred_tokens.dart';
import '../../core/enums.dart';
import '../../providers.dart';
import '../../data/local/models.dart';
import '../admin/admin_screen.dart';
import 'unsplash_background.dart';
import 'dart:io';
/// FORMAT HELPER: Duration → "HH:MM:SS"
String _fmtDuration(Duration d) {
final h = d.inHours.toString().padLeft(2, '0');
final m = (d.inMinutes % 60).toString().padLeft(2, '0');
final s = (d.inSeconds % 60).toString().padLeft(2, '0');
if (d.inHours > 0) return '$h:$m:$s';
return '$m:$s';
}
/// The primary display — clock, prayer cards, countdown, marquee.
class MainScreen extends ConsumerWidget {
const MainScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final clock = ref.watch(clockProvider).valueOrNull ?? DateTime.now();
final schedule = ref.watch(todayScheduleProvider);
final settings = ref.watch(settingsProvider);
final screenData = ref.watch(screenStateProvider);
final size = MediaQuery.of(context).size;
final s = size.width / 1920;
final fs = s * ref.watch(textScaleProvider);
final timeStr = DateFormat('HH:mm').format(clock);
final secStr = DateFormat(':ss').format(clock);
final dateGregorian = DateFormat('EEEE, d MMMM yyyy', 'id').format(clock);
return Container(
color: SacredColors.background,
child: Stack(
children: [
// ── Underlay 1: Branded local image (highest priority if set) ──
if (settings.brandedBgImage != null && settings.brandedBgImage!.isNotEmpty)
Positioned.fill(
child: Image.file(
File(settings.brandedBgImage!),
fit: BoxFit.cover,
color: Colors.black.withValues(alpha: 0.55),
colorBlendMode: BlendMode.darken,
),
)
else
// ── Underlay 2: API Unsplash Landscape (fallback) ──
const UnsplashBackground(),
// ── Background radial tint overlay ──
Positioned.fill(
child: Container(
decoration: BoxDecoration(
gradient: RadialGradient(
center: Alignment.center,
radius: 0.8,
colors: [
SacredColors.primary.withValues(alpha: 0.15),
SacredColors.background,
],
),
),
),
),
// ── Vignette ──
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
SacredColors.background.withValues(alpha: 0.8),
Colors.transparent,
SacredColors.background.withValues(alpha: 0.9),
],
stops: const [0.0, 0.4, 1.0],
),
),
),
),
// ── Main content column ──
Padding(
padding: EdgeInsets.symmetric(horizontal: 64 * s),
child: Column(
children: [
// ── HEADER ──
_buildHeader(context, s, fs, settings, dateGregorian),
// ── CENTER: Clock + Countdown ──
Expanded(
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Countdown pill
if (screenData.nextPrayer != null &&
screenData.timeUntilNext != null)
_buildCountdownPill(s, fs, screenData),
SizedBox(height: 16 * s),
// Massive Clock
Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
timeStr,
style: GoogleFonts.plusJakartaSans(
fontSize: 180 * s,
fontWeight: FontWeight.w800,
color: SacredColors.onSurface,
letterSpacing: -6 * s,
height: 1.0,
shadows: [
Shadow(
blurRadius: 40 * s,
color:
SacredColors.primary.withValues(alpha: 0.2),
),
],
),
),
Padding(
padding: EdgeInsets.only(top: 24 * s),
child: Text(
secStr,
style: GoogleFonts.plusJakartaSans(
fontSize: 72 * s,
fontWeight: FontWeight.w700,
color: SacredColors.primary,
letterSpacing: -1 * s,
),
),
),
],
),
// Decorative line
Container(
width: 240 * s,
height: 2 * s,
margin: EdgeInsets.only(top: 12 * s),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
Colors.transparent,
SacredColors.primary.withValues(alpha: 0.4),
Colors.transparent,
],
),
),
),
SizedBox(height: 16 * s),
// Date line
Text(
dateGregorian,
style: GoogleFonts.manrope(
fontSize: 24 * fs,
fontWeight: FontWeight.w500,
color: SacredColors.onSurfaceVariant,
letterSpacing: 1 * s,
),
),
// Secondary times (Imsak, Terbit, Dhuha)
if (schedule != null)
Padding(
padding: EdgeInsets.only(top: 24 * s),
child: _buildSecondaryTimes(s, fs, schedule, settings),
),
// Removed FRIDAY SPECIAL PANEL since its handled by dedicated JumatScreen
],
),
),
),
// ── FOOTER: Prayer Cards ──
if (schedule != null)
_buildPrayerCardsRow(
s, fs, schedule, screenData, settings, clock),
SizedBox(height: 16 * s),
// ── MARQUEE ──
_buildMarquee(s, fs, settings),
SizedBox(height: 12 * s),
],
),
),
],
),
);
}
Widget _buildHeader(BuildContext context, double s, double fs, AppSettings settings, String dateGregorian) {
final dateHijri = HijriDateFormatter.format(DateTime.now());
return Padding(
padding: EdgeInsets.only(top: 24 * s, bottom: 8 * s),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// Left: Mosque name + address (TAPPABLE FOR ADMIN PANEL)
InkWell(
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const AdminScreen()),
);
},
borderRadius: BorderRadius.circular(8 * s),
child: Padding(
padding: EdgeInsets.all(8.0 * s),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
settings.masjidName,
style: GoogleFonts.plusJakartaSans(
fontSize: 32 * s,
fontWeight: FontWeight.w700,
color: SacredColors.primary,
letterSpacing: -0.5 * s,
),
),
SizedBox(height: 4 * s),
Row(
children: [
HugeIcon(
icon: HugeIcons.strokeRoundedLocation01,
color: SacredColors.secondary,
size: 16 * s,
),
SizedBox(width: 4 * s),
Text(
settings.masjidAddress,
style: GoogleFonts.manrope(
fontSize: 14 * fs,
fontWeight: FontWeight.w500,
color: SacredColors.onSurface.withValues(alpha: 0.7),
letterSpacing: 0.5 * s,
),
),
],
),
],
),
),
),
// Right: Hijri date + mosque icon
Row(
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
dateHijri,
style: GoogleFonts.plusJakartaSans(
fontSize: 20 * s,
fontWeight: FontWeight.w700,
color: SacredColors.onSurface,
),
),
Text(
dateGregorian.toUpperCase(),
style: GoogleFonts.manrope(
fontSize: 12 * fs,
fontWeight: FontWeight.w500,
color: SacredColors.onSurfaceVariant,
letterSpacing: 2 * s,
),
),
],
),
SizedBox(width: 16 * s),
Container(
width: 48 * s,
height: 48 * s,
decoration: BoxDecoration(
color: SacredColors.surfaceContainerHighest,
shape: BoxShape.circle,
),
child: HugeIcon(
icon: HugeIcons.strokeRoundedHome01,
color: SacredColors.secondary,
size: 28 * s,
),
),
],
),
],
),
);
}
Widget _buildCountdownPill(double s, double fs, ScreenStateData screenData) {
return Container(
padding:
EdgeInsets.symmetric(horizontal: 24 * s, vertical: 8 * s),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(SacredRadii.full),
border: Border.all(
color: SacredColors.primary.withValues(alpha: 0.2), width: 1),
color: SacredColors.primary.withValues(alpha: 0.05),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// Pulsing dot
_PulsingDot(color: SacredColors.secondary, size: 10 * s),
SizedBox(width: 12 * s),
Text(
'Menuju Adzan ${screenData.nextPrayer?.displayLabel(isFriday: screenData.isFriday) ?? ""}: '
'${_fmtDuration(screenData.timeUntilNext!)}',
style: GoogleFonts.plusJakartaSans(
fontSize: 20 * fs,
fontWeight: FontWeight.w700,
color: SacredColors.secondary,
letterSpacing: 0.5 * s,
),
),
],
),
);
}
Widget _buildSecondaryTimes(
double s, double fs, DailyPrayerSchedule schedule, AppSettings settings) {
final items = <_SecondaryTimeItem>[];
if (settings.showImsak) {
items.add(_SecondaryTimeItem('Imsak', schedule.imsak));
}
if (settings.showTerbit) {
items.add(_SecondaryTimeItem('Terbit', schedule.terbit));
}
items.add(_SecondaryTimeItem('Dhuha', schedule.dhuha));
return Row(
mainAxisSize: MainAxisSize.min,
children: [
for (int i = 0; i < items.length; i++) ...[
Column(
children: [
Text(
items[i].label.toUpperCase(),
style: GoogleFonts.manrope(
fontSize: 10 * fs,
fontWeight: FontWeight.w700,
color: SacredColors.onSurfaceVariant,
letterSpacing: 3 * s,
),
),
SizedBox(height: 4 * s),
Text(
items[i].time,
style: GoogleFonts.plusJakartaSans(
fontSize: 28 * fs,
fontWeight: FontWeight.w600,
color: SacredColors.onSurface,
),
),
],
),
if (i < items.length - 1) ...[
Padding(
padding: EdgeInsets.symmetric(horizontal: 24 * s),
child: Container(
width: 1,
height: 40 * s,
color: SacredColors.outlineVariant.withValues(alpha: 0.3)),
),
],
],
],
);
}
Widget _buildPrayerCardsRow(double s, double fs, DailyPrayerSchedule schedule,
ScreenStateData screenData, AppSettings settings, DateTime clock) {
final prayers = [
_PrayerCardData(PrayerName.subuh, schedule.subuh,
'Iqamah ${_addMinutes(schedule.subuh, settings.iqomahSubuh)}'),
_PrayerCardData(PrayerName.dzuhur, schedule.dzuhur,
'Iqamah ${_addMinutes(schedule.dzuhur, settings.iqomahDzuhur)}'),
_PrayerCardData(PrayerName.ashar, schedule.ashar,
'Iqamah ${_addMinutes(schedule.ashar, settings.iqomahAshar)}'),
_PrayerCardData(PrayerName.maghrib, schedule.maghrib,
'Iqamah ${_addMinutes(schedule.maghrib, settings.iqomahMaghrib)}'),
_PrayerCardData(PrayerName.isya, schedule.isya,
'Iqamah ${_addMinutes(schedule.isya, settings.iqomahIsya)}'),
];
// Optionally insert Terbit
if (settings.showTerbit) {
prayers.insert(
1, _PrayerCardData(PrayerName.terbit, schedule.terbit, '-'));
}
return SizedBox(
height: (140 * s * settings.scaleCardBody).clamp(110 * s, 240 * s),
child: Row(
children: [
for (int i = 0; i < prayers.length; i++) ...[
Expanded(
child: _PrayerCard(
data: prayers[i],
isActive: screenData.nextPrayer == prayers[i].name,
isFriday: screenData.isFriday,
s: s,
fs: fs,
scaleLabel: settings.scaleCardLabel,
scaleBody: settings.scaleCardBody,
),
),
if (i < prayers.length - 1) SizedBox(width: 12 * s),
],
],
),
);
}
Widget _buildMarquee(double s, double fs, AppSettings settings) {
final texts = settings.runningTexts;
if (texts.isEmpty) return const SizedBox.shrink();
// Pad durations list to match texts length
final durations = List<int>.generate(
texts.length,
(i) => (i < settings.runningTextDurations.length)
? settings.runningTextDurations[i]
: 12,
);
return Container(
width: double.infinity,
height: 44 * s,
decoration: BoxDecoration(
color: SacredColors.background.withValues(alpha: 0.9),
border: Border(
top: BorderSide(
color: SacredColors.surfaceContainerHighest.withValues(alpha: 0.1),
),
),
),
child: ClipRect(
child: _RunningTextWidget(
texts: texts,
durations: durations,
animType: settings.marqueeAnimType,
style: GoogleFonts.manrope(
fontSize: 16 * fs * settings.scaleRunningText,
fontWeight: FontWeight.w500,
color: SacredColors.secondary,
letterSpacing: 0.8 * s,
),
),
),
);
}
String _addMinutes(String time, int minutes) {
final parts = time.split(':');
final h = int.parse(parts[0]);
final m = int.parse(parts[1]);
final dt = DateTime(2000, 1, 1, h, m).add(Duration(minutes: minutes));
return DateFormat('HH:mm').format(dt);
}
}
// ─── Supporting widgets ───
class _SecondaryTimeItem {
final String label;
final String time;
_SecondaryTimeItem(this.label, this.time);
}
class _PrayerCardData {
final PrayerName name;
final String time;
final String iqomahLabel;
_PrayerCardData(this.name, this.time, this.iqomahLabel);
}
class _PrayerCard extends StatelessWidget {
final _PrayerCardData data;
final bool isActive;
final bool isFriday;
final double s;
final double fs;
final double scaleLabel; // controls prayer name label size
final double scaleBody; // controls time + iqomah text size
const _PrayerCard({
required this.data,
required this.isActive,
required this.isFriday,
required this.s,
required this.fs,
this.scaleLabel = 1.0,
this.scaleBody = 1.0,
});
@override
Widget build(BuildContext context) {
final label = data.name.displayLabel(isFriday: isFriday);
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
padding: EdgeInsets.all(16 * s),
decoration: BoxDecoration(
color: isActive
? SacredColors.primaryContainer
: SacredColors.surfaceContainerLow,
borderRadius: BorderRadius.circular(SacredRadii.xl),
border: isActive
? Border.all(color: SacredColors.primary, width: 2 * s)
: null,
boxShadow: isActive
? [
BoxShadow(
color: SacredColors.primary.withValues(alpha: 0.1),
blurRadius: 40 * s,
),
]
: null,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
label.toUpperCase(),
style: GoogleFonts.manrope(
fontSize: 12 * fs * scaleLabel,
fontWeight: FontWeight.w700,
color: isActive
? SacredColors.onPrimaryContainer
: SacredColors.onSurfaceVariant,
letterSpacing: 2 * s,
),
),
if (isActive)
Icon(Icons.notifications_active,
color: SacredColors.onPrimaryContainer, size: 16 * s),
],
),
Text(
data.time,
style: GoogleFonts.plusJakartaSans(
fontSize: 32 * fs * scaleBody,
fontWeight: FontWeight.w700,
color: isActive
? SacredColors.onPrimaryContainer
: SacredColors.onSurface,
),
),
Text(
data.iqomahLabel,
style: GoogleFonts.manrope(
fontSize: 10 * fs * scaleBody,
color: isActive
? SacredColors.onPrimaryContainer.withValues(alpha: 0.8)
: SacredColors.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
);
}
}
class _PulsingDot extends StatefulWidget {
final Color color;
final double size;
const _PulsingDot({required this.color, required this.size});
@override
State<_PulsingDot> createState() => _PulsingDotState();
}
class _PulsingDotState extends State<_PulsingDot>
with SingleTickerProviderStateMixin {
late AnimationController _ctrl;
@override
void initState() {
super.initState();
_ctrl = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1200),
)..repeat();
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SizedBox(
width: widget.size,
height: widget.size,
child: Stack(
children: [
FadeTransition(
opacity: Tween(begin: 0.75, end: 0.0).animate(_ctrl),
child: ScaleTransition(
scale: Tween(begin: 1.0, end: 2.0).animate(_ctrl),
child: Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: widget.color,
),
),
),
),
Center(
child: Container(
width: widget.size,
height: widget.size,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: widget.color,
),
),
),
],
),
);
}
}
// ─── Running Text Widget (marquee + fade modes) ───
class _RunningTextWidget extends StatefulWidget {
final List<String> texts;
final List<int> durations; // per-item seconds
final String animType; // 'marquee' or 'fade'
final TextStyle style;
const _RunningTextWidget({
required this.texts,
required this.durations,
required this.animType,
required this.style,
});
@override
State<_RunningTextWidget> createState() => _RunningTextWidgetState();
}
class _RunningTextWidgetState extends State<_RunningTextWidget>
with TickerProviderStateMixin {
int _index = 0;
bool _disposed = false;
late AnimationController _fadeCtrl;
late AnimationController _scrollCtrl;
@override
void initState() {
super.initState();
_fadeCtrl = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 600),
);
_scrollCtrl = AnimationController(vsync: this);
_startCycle();
}
void _startCycle() async {
try {
while (!_disposed) {
if (widget.texts.isEmpty) {
await Future.delayed(const Duration(seconds: 1));
continue;
}
final dur = widget.durations[_index];
if (widget.animType == 'fade') {
if (_disposed) break;
await _fadeCtrl.forward().orCancel;
if (_disposed) break;
await Future.delayed(Duration(seconds: dur));
if (_disposed) break;
await _fadeCtrl.reverse().orCancel;
} else {
if (_disposed) break;
_scrollCtrl.duration = Duration(seconds: dur);
_scrollCtrl.reset();
await _scrollCtrl.forward().orCancel;
}
if (_disposed) break;
if (mounted) {
setState(() {
_index = (_index + 1) % widget.texts.length;
});
}
}
} on TickerCanceled {
// Widget disposed while animation was running — exit cleanly
} catch (e) {
if (!_disposed) rethrow;
}
}
@override
void dispose() {
_disposed = true;
_fadeCtrl.dispose();
_scrollCtrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final text = widget.texts[_index];
if (widget.animType == 'fade') {
return Center(
child: FadeTransition(
opacity: _fadeCtrl,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.info_outline,
color: SacredColors.secondary, size: 16),
const SizedBox(width: 8),
Text(text, style: widget.style, maxLines: 1),
],
),
),
);
}
// Marquee mode
return AnimatedBuilder(
animation: _scrollCtrl,
builder: (context, child) {
final width = MediaQuery.of(context).size.width;
final offset = _scrollCtrl.value * (width + 600);
return Transform.translate(
offset: Offset(width - offset, 0),
child: child,
);
},
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.info_outline, color: SacredColors.secondary, size: 16),
const SizedBox(width: 8),
Text(text, style: widget.style, maxLines: 1),
const SizedBox(width: 80),
Icon(Icons.circle, color: SacredColors.secondary.withValues(alpha: 0.4), size: 6),
const SizedBox(width: 80),
],
),
);
}
}

View File

@@ -0,0 +1,201 @@
import 'dart:io';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../core/sacred_tokens.dart';
import '../../providers.dart';
class SlideshowScreen extends ConsumerStatefulWidget {
const SlideshowScreen({super.key});
@override
ConsumerState<SlideshowScreen> createState() => _SlideshowScreenState();
}
class _SlideshowScreenState extends ConsumerState<SlideshowScreen> {
static int _globalImageIndex = 0;
int _localImageIndex = 0;
@override
void initState() {
super.initState();
final settings = ref.read(settingsProvider);
if (settings.slideshowImages.isNotEmpty) {
_localImageIndex = _globalImageIndex;
_globalImageIndex = (_globalImageIndex + 1) % settings.slideshowImages.length;
}
}
@override
Widget build(BuildContext context) {
final size = MediaQuery.of(context).size;
final s = size.width / 1920;
final screenData = ref.watch(screenStateProvider);
final settings = ref.watch(settingsProvider);
Widget renderImage() {
if (settings.slideshowImages.isEmpty) {
return _buildErrorImage(s);
}
final imgPath = settings.slideshowImages[_localImageIndex % settings.slideshowImages.length];
return Image.file(
File(imgPath),
fit: BoxFit.cover,
errorBuilder: (ctx, err, stack) => _buildErrorImage(s),
);
}
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
fit: StackFit.expand,
children: [
// 1. Poster Image
renderImage(),
// 2. Subtle Dark Gradient Overlay at bottom so the "glass bar" pops out cleanly
Positioned(
left: 0,
right: 0,
bottom: 0,
height: 300 * s,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [
Colors.black.withValues(alpha: 0.8),
Colors.transparent,
],
),
),
),
),
// 3. Glassmorphic Bottom Bar (Always showing Clock and Next Prayer)
Positioned(
left: 64 * s,
right: 64 * s,
bottom: 64 * s,
child: ClipRRect(
borderRadius: BorderRadius.circular(SacredRadii.xl),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 40, sigmaY: 40),
child: Container(
padding: EdgeInsets.symmetric(horizontal: 48 * s, vertical: 32 * s),
decoration: BoxDecoration(
color: SacredColors.surfaceContainerLowest.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(SacredRadii.xl),
border: Border.all(color: Colors.white.withValues(alpha: 0.1)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// Left: Mosque Name
Text(
settings.masjidName,
style: GoogleFonts.plusJakartaSans(
fontSize: 32 * s,
fontWeight: FontWeight.w800,
color: Colors.white,
letterSpacing: 1 * s,
),
),
// Center: Clock
_buildMiniClock(s, screenData),
// Right: Next Prayer
if (screenData.nextPrayer != null && screenData.timeUntilNext != null)
_buildNextPrayer(s, screenData),
],
),
),
),
),
),
],
),
);
}
Widget _buildMiniClock(double s, ScreenStateData data) {
final timeStr = "${data.now.hour.toString().padLeft(2, '0')}:${data.now.minute.toString().padLeft(2, '0')}";
final secStr = data.now.second.toString().padLeft(2, '0');
return Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
timeStr,
style: GoogleFonts.plusJakartaSans(
fontSize: 64 * s,
fontWeight: FontWeight.w800,
color: Colors.white,
height: 1.0,
),
),
SizedBox(width: 8 * s),
Padding(
padding: EdgeInsets.only(bottom: 8 * s),
child: Text(
secStr,
style: GoogleFonts.plusJakartaSans(
fontSize: 32 * s,
fontWeight: FontWeight.w700,
color: SacredColors.primary,
),
),
),
],
);
}
Widget _buildNextPrayer(double s, ScreenStateData data) {
final dur = data.timeUntilNext!;
final h = dur.inHours;
final m = (dur.inMinutes % 60);
final countDownStr = h > 0 ? "-$h jam $m mnt" : "-$m mnt";
final prayerTitle = data.nextPrayer!.id.toUpperCase();
return Column(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'MENJELANG $prayerTitle',
style: GoogleFonts.plusJakartaSans(
fontSize: 20 * s,
fontWeight: FontWeight.w700,
color: SacredColors.onSurfaceVariant.withValues(alpha: 0.9),
letterSpacing: 2 * s,
),
),
SizedBox(height: 4 * s),
Text(
countDownStr,
style: GoogleFonts.manrope(
fontSize: 28 * s,
fontWeight: FontWeight.w800,
color: SacredColors.primary,
),
),
],
);
}
Widget _buildErrorImage(double s) {
return Container(
color: SacredColors.surfaceContainerLow,
child: Center(
child: Icon(Icons.broken_image, size: 64 * s, color: SacredColors.onSurfaceVariant),
),
);
}
}

View File

@@ -0,0 +1,102 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../data/services/unsplash_service.dart';
import '../../providers.dart';
/// Renders a securely rotating background using Unsplash API.
class UnsplashBackground extends ConsumerStatefulWidget {
const UnsplashBackground({super.key});
@override
ConsumerState<UnsplashBackground> createState() => _UnsplashBackgroundState();
}
class _UnsplashBackgroundState extends ConsumerState<UnsplashBackground> {
List<String> _urls = [];
int _currentIndex = 0;
Timer? _rotationTimer;
String? _lastKeyword;
int? _lastRotationHours;
@override
void initState() {
super.initState();
_initDataAndTimer();
}
void _initDataAndTimer() async {
final settings = ref.read(settingsProvider);
_lastKeyword = settings.unsplashKeyword;
_lastRotationHours = settings.unsplashRotationHours;
await _fetchImages(settings.unsplashKeyword);
_startTimer(settings.unsplashRotationHours);
}
Future<void> _fetchImages(String keyword) async {
final urls = await UnsplashService.instance.fetchLandscapeBackgrounds(keyword);
if (urls.isNotEmpty && mounted) {
setState(() {
_urls = urls;
_currentIndex = 0;
});
}
}
void _startTimer(int hours) {
_rotationTimer?.cancel();
if (hours <= 0) return;
_rotationTimer = Timer.periodic(Duration(hours: hours), (_) {
if (_urls.isNotEmpty && mounted) {
setState(() {
_currentIndex = (_currentIndex + 1) % _urls.length;
});
}
});
}
@override
void dispose() {
_rotationTimer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final settings = ref.watch(settingsProvider);
// Watch for config changes
if (settings.unsplashKeyword != _lastKeyword) {
_lastKeyword = settings.unsplashKeyword;
// Re-fetch images organically
_fetchImages(settings.unsplashKeyword);
}
if (settings.unsplashRotationHours != _lastRotationHours) {
_lastRotationHours = settings.unsplashRotationHours;
_startTimer(settings.unsplashRotationHours);
}
if (!settings.useUnsplashBackground || _urls.isEmpty) {
return const SizedBox.shrink(); // Fallback to flat background handled underneath
}
return AnimatedSwitcher(
duration: const Duration(seconds: 3),
transitionBuilder: (child, animation) => FadeTransition(opacity: animation, child: child),
child: Image.network(
_urls[_currentIndex],
key: ValueKey(_urls[_currentIndex]),
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
// Soft opacity behind the MainScreen's dark glass vignette
color: Colors.black.withValues(alpha: 0.5),
colorBlendMode: BlendMode.darken,
),
);
}
}

78
lib/main.dart Normal file
View File

@@ -0,0 +1,78 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
import 'core/sacred_tokens.dart';
import 'data/local/models.dart';
import 'features/home/home_view.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Landscape-only for TV
await SystemChrome.setPreferredOrientations([
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
]);
// Hide system overlays for full-screen kiosk mode
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
// Initialize Hive
await Hive.initFlutter();
Hive.registerAdapter(AppSettingsAdapter());
Hive.registerAdapter(DailyPrayerScheduleAdapter());
await Hive.openBox<AppSettings>(HiveBoxes.settings);
await Hive.openBox<DailyPrayerSchedule>(HiveBoxes.prayerSchedule);
// Seed defaults if first launch
final settingsBox = Hive.box<AppSettings>(HiveBoxes.settings);
if (settingsBox.get('default') == null) {
await settingsBox.put('default', AppSettings());
}
// Initialize date formatting for Indonesian locale
await initializeDateFormatting('id_ID');
// Keep screen awake — CRITICAL for 24/7 TV operation
await WakelockPlus.enable();
runApp(
const ProviderScope(
child: JamShalatApp(),
),
);
}
class JamShalatApp extends ConsumerWidget {
const JamShalatApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// textScaleProvider will be used selectively in child components.
return MaterialApp(
title: 'Jam Shalat Digital',
debugShowCheckedModeBanner: false,
theme: ThemeData(
useMaterial3: true,
fontFamily: 'Manrope',
scaffoldBackgroundColor: SacredColors.background,
colorScheme: const ColorScheme.dark(
primary: SacredColors.primary,
secondary: SacredColors.secondary,
surface: SacredColors.surface,
error: SacredColors.error,
onPrimary: SacredColors.onPrimary,
onSecondary: SacredColors.onSecondary,
onSurface: SacredColors.onSurface,
onError: Color(0xFF690005),
),
),
home: const HomeView(),
);
}
}

Some files were not shown because too many files have changed in this diff Show More