Initial project import and stabilization baseline
45
.gitignore
vendored
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1 @@
|
|||||||
|
include: package:flutter_lints/flutter.yaml
|
||||||
14
android/.gitignore
vendored
Normal 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
|
||||||
44
android/app/build.gradle.kts
Normal 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 = "../.."
|
||||||
|
}
|
||||||
7
android/app/src/debug/AndroidManifest.xml
Normal 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>
|
||||||
46
android/app/src/main/AndroidManifest.xml
Normal 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>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package com.jamshalat.jamshalat_masjid_screen
|
||||||
|
|
||||||
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
|
||||||
|
class MainActivity : FlutterActivity()
|
||||||
12
android/app/src/main/res/drawable-v21/launch_background.xml
Normal 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>
|
||||||
12
android/app/src/main/res/drawable/launch_background.xml
Normal 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>
|
||||||
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 544 B |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 442 B |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 721 B |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
18
android/app/src/main/res/values-night/styles.xml
Normal 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>
|
||||||
18
android/app/src/main/res/values/styles.xml
Normal 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>
|
||||||
7
android/app/src/profile/AndroidManifest.xml
Normal 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
@@ -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)
|
||||||
|
}
|
||||||
2
android/gradle.properties
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||||
|
android.useAndroidX=true
|
||||||
5
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
|
||||||
28
android/settings.gradle.kts
Normal 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"
|
||||||
BIN
assets/fonts/Manrope-Bold.ttf
Normal file
BIN
assets/fonts/Manrope-ExtraBold.ttf
Normal file
BIN
assets/fonts/Manrope-ExtraLight.ttf
Normal file
BIN
assets/fonts/Manrope-Light.ttf
Normal file
BIN
assets/fonts/Manrope-Medium.ttf
Normal file
BIN
assets/fonts/Manrope-Regular.ttf
Normal file
BIN
assets/fonts/Manrope-SemiBold.ttf
Normal file
1469
assets/fonts/PlusJakartaSans-Bold.ttf
Normal file
1469
assets/fonts/PlusJakartaSans-ExtraBold.ttf
Normal file
1469
assets/fonts/PlusJakartaSans-Regular.ttf
Normal file
1469
assets/fonts/PlusJakartaSans-SemiBold.ttf
Normal file
BIN
assets/images/icon.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
assets/sounds/3-detik-countdown.mp3
Executable file
BIN
assets/sounds/beep.mp3
Executable file
130
brief.md
Normal 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
@@ -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
|
||||||
24
ios/Flutter/AppFrameworkInfo.plist
Normal 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>
|
||||||
2
ios/Flutter/Debug.xcconfig
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
|
||||||
|
#include "Generated.xcconfig"
|
||||||
2
ios/Flutter/Release.xcconfig
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
|
||||||
|
#include "Generated.xcconfig"
|
||||||
43
ios/Podfile
Normal 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
|
||||||
620
ios/Runner.xcodeproj/project.pbxproj
Normal 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 */;
|
||||||
|
}
|
||||||
7
ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Workspace
|
||||||
|
version = "1.0">
|
||||||
|
<FileRef
|
||||||
|
location = "self:">
|
||||||
|
</FileRef>
|
||||||
|
</Workspace>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
101
ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
Normal 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>
|
||||||
7
ios/Runner.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Workspace
|
||||||
|
version = "1.0">
|
||||||
|
<FileRef
|
||||||
|
location = "group:Runner.xcodeproj">
|
||||||
|
</FileRef>
|
||||||
|
</Workspace>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
16
ios/Runner/AppDelegate.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
122
ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 295 B |
|
After Width: | Height: | Size: 406 B |
|
After Width: | Height: | Size: 450 B |
|
After Width: | Height: | Size: 282 B |
|
After Width: | Height: | Size: 462 B |
|
After Width: | Height: | Size: 704 B |
|
After Width: | Height: | Size: 406 B |
|
After Width: | Height: | Size: 586 B |
|
After Width: | Height: | Size: 862 B |
|
After Width: | Height: | Size: 862 B |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 762 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
23
ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json
vendored
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png
vendored
Normal file
|
After Width: | Height: | Size: 68 B |
BIN
ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 68 B |
BIN
ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 68 B |
5
ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md
vendored
Normal 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.
|
||||||
37
ios/Runner/Base.lproj/LaunchScreen.storyboard
Normal 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>
|
||||||
26
ios/Runner/Base.lproj/Main.storyboard
Normal 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
@@ -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>
|
||||||
1
ios/Runner/Runner-Bridging-Header.h
Normal file
@@ -0,0 +1 @@
|
|||||||
|
#import "GeneratedPluginRegistrant.h"
|
||||||
6
ios/Runner/SceneDelegate.swift
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import Flutter
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
class SceneDelegate: FlutterSceneDelegate {
|
||||||
|
|
||||||
|
}
|
||||||
12
ios/RunnerTests/RunnerTests.swift
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
111
lib/data/services/myquran_service.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
46
lib/data/services/sound_service.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
93
lib/data/services/sync_service.dart
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
48
lib/data/services/unsplash_service.dart
Normal 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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
1697
lib/features/admin/admin_screen.dart
Normal file
330
lib/features/home/adzan_screen.dart
Normal 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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
108
lib/features/home/black_screen.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
145
lib/features/home/home_view.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
367
lib/features/home/iqomah_screen.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
530
lib/features/home/jumat_screen.dart
Normal 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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
202
lib/features/home/khutbah_screen.dart
Normal 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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
791
lib/features/home/main_screen.dart
Normal 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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
201
lib/features/home/slideshow_screen.dart
Normal 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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
102
lib/features/home/unsplash_background.dart
Normal 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
@@ -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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||