Compare commits

...

2 Commits

Author SHA1 Message Date
Dwindi Ramadhana
2d09b5b356 Polish navigation, Quran flows, and sharing UX 2026-03-18 00:07:10 +07:00
Dwindi Ramadhana
a049129a35 feat: checkpoint API migration and dzikir UX updates 2026-03-16 00:30:32 +07:00
127 changed files with 15419 additions and 2694 deletions

View File

@@ -18,6 +18,12 @@ migration:
- platform: android
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
- platform: ios
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
- platform: macos
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
# User provided section

View File

@@ -13,6 +13,15 @@ val keystoreProperties = Properties()
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
}
val hasReleaseKeystore = listOf(
"keyAlias",
"keyPassword",
"storeFile",
"storePassword",
).all { key ->
val value = keystoreProperties[key] as String?
!value.isNullOrBlank()
}
android {
namespace = "com.jamshalat.diary"
@@ -30,6 +39,7 @@ android {
}
signingConfigs {
if (hasReleaseKeystore) {
create("release") {
keyAlias = keystoreProperties["keyAlias"] as String?
keyPassword = keystoreProperties["keyPassword"] as String?
@@ -38,6 +48,7 @@ android {
storeType = "PKCS12"
}
}
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
@@ -52,9 +63,13 @@ android {
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("release")
// Use release keystore if configured, otherwise fallback to debug
// signing so local release APK builds remain possible.
signingConfig = if (hasReleaseKeystore) {
signingConfigs.getByName("release")
} else {
signingConfigs.getByName("debug")
}
}
}
}

View File

@@ -2,6 +2,11 @@
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
<application
android:label="Jamshalat Diary"
android:name="${applicationName}"
@@ -33,6 +38,21 @@
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<service
android:name="com.ryanheise.audioservice.AudioService"
android:exported="true"
android:foregroundServiceType="mediaPlayback">
<intent-filter>
<action android:name="android.media.browse.MediaBrowserService" />
</intent-filter>
</service>
<receiver
android:name="com.ryanheise.audioservice.MediaButtonReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>
</receiver>
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and

View File

@@ -65,6 +65,11 @@ public final class GeneratedPluginRegistrant {
} catch (Exception e) {
Log.e(TAG, "Error registering plugin path_provider_android, io.flutter.plugins.pathprovider.PathProviderPlugin", e);
}
try {
flutterEngine.getPlugins().add(new dev.fluttercommunity.plus.share.SharePlusPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin share_plus, dev.fluttercommunity.plus.share.SharePlusPlugin", e);
}
try {
flutterEngine.getPlugins().add(new com.tekartik.sqflite.SqflitePlugin());
} catch (Exception e) {

View File

@@ -1,6 +1,44 @@
package com.jamshalat.diary
import io.flutter.embedding.android.FlutterActivity
import android.hardware.GeomagneticField
import com.ryanheise.audioservice.AudioServiceActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
class MainActivity: FlutterActivity() {
class MainActivity : AudioServiceActivity() {
companion object {
private const val GEOMAGNETIC_CHANNEL = "com.jamshalat.diary/geomagnetic"
private const val DECLINATION_METHOD = "getDeclination"
}
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, GEOMAGNETIC_CHANNEL)
.setMethodCallHandler(::handleGeomagneticMethodCall)
}
private fun handleGeomagneticMethodCall(call: MethodCall, result: MethodChannel.Result) {
if (call.method != DECLINATION_METHOD) {
result.notImplemented()
return
}
val latitude = call.argument<Double>("latitude")
val longitude = call.argument<Double>("longitude")
if (latitude == null || longitude == null) {
result.error("INVALID_ARGS", "Latitude and longitude are required.", null)
return
}
val altitude = call.argument<Double>("altitude") ?: 0.0
val timestamp = call.argument<Long>("timestamp") ?: System.currentTimeMillis()
val field = GeomagneticField(
latitude.toFloat(),
longitude.toFloat(),
altitude.toFloat(),
timestamp,
)
result.success(field.declination.toDouble())
}
}

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 B

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 B

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 B

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB

View File

@@ -1,5 +1,5 @@
sdk.dir=/Users/dwindown/Library/Android/sdk
flutter.sdk=/Users/dwindown/FlutterDev/flutter
flutter.sdk=/opt/homebrew/share/flutter
flutter.buildMode=release
flutter.versionName=1.0.0
flutter.versionCode=1

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
assets/images/blob.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

BIN
assets/images/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

248
docs/notification-plan.md Normal file
View File

@@ -0,0 +1,248 @@
# Notification Plan (Alerts + Inbox)
Last updated: 2026-03-16
Owner: Product + Mobile
Scope: `jamshalat_diary` (Flutter)
## Implementation Status (2026-03-17)
- Phase 1: Implemented.
- Phase 2: Implemented (Notification Center + unread badge + inbox persistence).
- Phase 3: Implemented (daily checklist reminder scheduling, streak-risk messages, weekly summary, quiet-hours and daily cap preferences).
- Phase 4: Partially implemented via remote-content sync and remote-push ingestion bridge.
- External pending for full Phase 4: SDK/provider wiring (FCM/APNs credentials, token registration, backend push dispatch).
## 1. Problem Statement
Current app has mixed meaning for "notification":
- Device push alert (time-sensitive, appears in system drawer)
- In-app message feed (read-later information)
This causes unclear UX around the bell icon and settings.
## 2. Product Model
Split into two clear products:
1. **Alerts** (`Pemberitahuan`)
- Time-sensitive pushes to OS notification drawer.
- Can ring/vibrate.
- Expire quickly.
2. **Inbox** (`Pesan`)
- In-app list of messages user can read later.
- Has read/unread state.
- No mandatory sound.
Default rule:
- **Push only**: urgent + expiring event.
- **Inbox only**: informational/non-urgent event.
- **Push + Inbox**: important event that also needs follow-up context.
## 3. Terminology (User-Facing)
- Bell icon label/context: `Pemberitahuan`
- Notification center tabs:
- `Alarm` (alert history + system-triggered items)
- `Pesan` (inbox/read-later)
## 4. Event Policy Matrix
| Event | Push Drawer | Inbox | Sound | Expiry |
|---|---|---|---|---|
| Adzan time entered | Yes | Optional (off by default) | Yes | At end of prayer window |
| Iqamah reminder | Yes | No | Yes | At iqamah time |
| Next prayer in 10 minutes | Optional | No | Soft | At prayer start |
| Daily checklist reminder | Yes (if enabled) | No | Optional | End of day |
| Streak at risk (Tilawah/Dzikir) | Optional | Yes | No | End of day |
| Weekly worship summary | No | Yes | No | 7 days |
| Permission blocked (notif/exact alarm/location) | Yes | Yes | No | When resolved |
| Schedule/location stale | Yes | Yes | No | When resolved |
| New app content (Doa/Hadits) | No | Yes | No | 30 days |
## 5. Architecture
## 5.1 Channels
- **Local scheduled alerts** (already started in app): Adzan + Iqamah.
- **In-app inbox storage** (new): persisted read/unread messages.
- **Remote push** (future): FCM/APNs for server-driven campaigns/events.
## 5.2 Data Sources
- Prayer schedules: `MyQuranSholatService`.
- User prefs: `AppSettings` + new notification preference model.
- Device status: notification permission, exact alarm permission, location service.
## 5.3 Decision Engine
Input:
- event type
- urgency
- TTL
- user preferences
- cooldown/frequency cap
Output:
- deliver to `push`, `inbox`, or both.
Pseudo rule:
```text
if event.isTimeCritical && event.ttlShort:
push
if event.needsFollowUp || event.referenceContent:
inbox
if event.push && event.inboxPolicy == mirror:
push + inbox
```
## 6. Data Model
## 6.1 Inbox Item (Hive)
Suggested new box: `notification_inbox`
```json
{
"id": "uuid",
"type": "streak_risk | summary | system | content | prayer",
"title": "string",
"body": "string",
"createdAt": "iso8601",
"expiresAt": "iso8601|null",
"readAt": "iso8601|null",
"isPinned": false,
"deeplink": "/route/path",
"meta": {
"cityId": "string|null",
"prayerKey": "fajr|dhuhr|asr|maghrib|isha|null",
"date": "yyyy-MM-dd|null"
},
"source": "local|remote",
"dedupeKey": "string"
}
```
## 6.2 Preferences
Use existing `AppSettings` for:
- `adhanEnabled` (per prayer)
- `iqamahOffset` (per prayer)
Add new fields (either in `AppSettings` or separate box):
- `alertsEnabled` (global)
- `inboxEnabled` (global)
- `streakRiskEnabled`
- `dailyChecklistReminderEnabled`
- `weeklySummaryEnabled`
- `quietHoursStart` / `quietHoursEnd`
- `maxNonPrayerPushPerDay` (default 2)
## 6.3 Badge Source
- Bell badge count = unread inbox count only.
- Do not include "already fired system push" count.
## 7. Local Notification Spec (Current + Next)
## 7.1 Channels
- `adhan_channel`: max importance, sound on.
- `iqamah_channel`: high importance, sound on.
- Later:
- `habit_channel` (streak/checklist)
- `system_channel` (permission/location)
## 7.2 Permission Strategy
- Request `POST_NOTIFICATIONS` and exact alarm capability only when needed.
- If denied:
- create Inbox warning item
- show non-blocking UI notice
## 7.3 Scheduling Rules
- Keep 2-day rolling window (today + tomorrow).
- Resync on:
- app start
- city change
- prayer settings change
- adzan toggle change
- daily boundary change (00:05 local)
- Deduplicate by key: `cityId + date + prayer + kind(adhan/iqamah)`.
## 8. UX Specification
## 8.1 Bell Icon
- Tap action: open Notification Center.
- Badge: unread inbox count.
- Long-press (optional): quick actions
- toggle `Alarm Sholat`
- open Iqamah settings
- sync now
## 8.2 Notification Center Screen
Tabs:
- `Alarm`
- recent fired alerts (read-only timeline, optional v2)
- `Pesan`
- unread/read list with filters (`Semua`, `Belum Dibaca`, `Sistem`)
Item actions:
- Tap: open deeplink target.
- Swipe: mark read/unread.
- Optional: pin important item.
## 8.3 Copy Guidelines
- Urgent: concise, action-first.
- Inbox: context-rich but short (max ~120 chars body preview).
- Prayer push examples:
- `Adzan • Subuh`
- `Waktu sholat Subuh telah masuk.`
## 9. Deep Link Map
- Permission blocked -> `/settings` (notifications section)
- Location disabled -> `/settings` or location setup section
- Streak risk Tilawah -> `/quran`
- Streak risk Dzikir -> `/tools/dzikir`
- Weekly summary -> `/laporan`
- Prayer event details -> `/` (Beranda)
## 10. Analytics
Track:
- `notif_push_scheduled`
- `notif_push_fired`
- `notif_push_opened`
- `notif_inbox_created`
- `notif_inbox_opened`
- `notif_mark_read`
- `notif_settings_changed`
- `notif_permission_denied`
Dimensions:
- `event_type`
- `channel` (`push|inbox|both`)
- `city_id`
- `simple_mode`
## 11. Rollout Plan
## Phase 1 (Now)
- Stabilize prayer alerts (Adzan + Iqamah) local scheduling.
- Working sound toggle in hero card and settings.
- Permission and exact-alarm checks.
## Phase 2
- Build Notification Center page + unread badge.
- Add inbox persistence and read/unread actions.
- Add system warning messages to inbox.
## Phase 3
- Add non-prayer reminders (checklist, streak risk, weekly summary).
- Add cooldown/frequency cap and quiet hours.
## Phase 4
- Remote push integration (FCM/APNs).
- Server-defined campaigns/content updates.
## 12. Acceptance Criteria
- Toggling adzan off removes all pending adzan/iqamah notifications.
- Toggling adzan on schedules valid upcoming notifications for enabled prayers.
- Changing iqamah minutes updates future scheduled iqamah alerts immediately.
- Bell opens Notification Center (after Phase 2).
- Unread badge count reflects inbox unread only.
- No duplicate notifications for same prayer/time/city.
## 13. QA Checklist
- Android 13/14: permission denied -> graceful fallback.
- Exact alarm disabled -> user warning path works.
- Timezone changes -> schedule recalculates correctly.
- Day rollover -> next-day notifications still present.
- City changes -> old city schedules removed; new city schedules added.
- Device reboot (future): optional reschedule receiver if needed.
## 14. Open Decisions
1. Should adzan events be mirrored into inbox by default?
2. Should quiet hours suppress non-prayer push only, or all push except adzan?
3. Should `Alarm` tab show only fired events or also pending schedule preview?

View File

@@ -0,0 +1,81 @@
# Dzikir Display Mode UX Brief
## 1) Objective
Provide two complementary experiences for Dzikir:
- **Daftar (Baris)** for fast scanning and jumping between items.
- **Fokus (Slide)** for one-item focus with consistent thumb reach and counting flow.
This mode applies to all Dzikir tabs: **Pagi**, **Petang**, and **Sesudah Shalat**.
## 2) Settings Specification
Section name in Settings: **Tampilan Dzikir**
| Label | Type | Options | Default | Visibility |
|---|---|---|---|---|
| `Mode Tampilan Dzikir` | Segmented | `Daftar (Baris)` / `Fokus (Slide)` | `Daftar (Baris)` | Always |
| `Posisi Tombol Hitung` | Segmented | `Pill Bawah (Disarankan)` / `Bulat Kanan Bawah` | `Pill Bawah (Disarankan)` | Only in `Fokus (Slide)` |
| `Lanjut Otomatis Saat Target Tercapai` | Switch | `On/Off` | `On` | Only in `Fokus (Slide)` |
| `Getaran Saat Hitung` | Switch | `On/Off` | `On` | Always |
## 3) Interaction Rules
### A. Mode: Daftar (Baris)
- Keep current row-based list and per-row counter pattern.
- Users can scan, jump, and increment any row directly.
- Counter behavior remains per item, per day.
### B. Mode: Fokus (Slide)
- Display exactly **one dzikir item per slide**.
- Horizontal swipe moves between dzikir items.
- Counter button is fixed in one location (based on selected button position).
- Top area displays progress: `Item X dari Y`.
- Tapping counter increments by `+1` until target.
- When target reached:
- Mark item as complete.
- If `Lanjut Otomatis... = On`, move to next slide automatically (except last item).
## 4) Button Placement Recommendation
Primary recommendation:
- **Pill Bawah (Disarankan)** as default in Focus mode.
Reason:
- Better one-handed ergonomics.
- Consistent location improves counting rhythm.
- Larger tap target lowers miss taps while reciting.
Optional style:
- **Bulat Kanan Bawah** for users preferring minimal visual footprint.
## 5) Data & State Behavior
- Counter data is shared across modes (switching mode must not reset progress).
- Existing daily tracking logic remains unchanged.
- Switching mode keeps current tab (`Pagi/Petang/Sesudah Shalat`) intact.
- Completed state must be reflected identically in both modes.
## 6) Completion & Feedback UX
- Counter states: `normal` and `completed`.
- Completed label example: `Selesai`.
- Last item completion feedback:
- Show subtle confirmation message: `Semua dzikir pada tab ini selesai`.
- Empty or missing data:
- Show friendly empty state, never blank screen.
## 7) Default Product Decision
- App default: **Daftar (Baris)** for broad familiarity.
- Advanced/focus users can enable **Fokus (Slide)**.
- In Focus mode, default button placement: **Pill Bawah (Disarankan)**.
## 8) Success Criteria
- Users can switch between modes without losing count progress.
- Focus mode reduces hand travel for repeated taps.
- Both modes remain consistent across all Dzikir tabs.
- No behavioral mismatch between count target, completion state, and progress indicator.

173
hugeicons-migration-spec.md Normal file
View File

@@ -0,0 +1,173 @@
# HugeIcons Migration Spec
## Scope
- Replace current Lucide icon usage with HugeIcons targets.
- Prioritize Islamic-context icons first (bottom nav, Beranda, Lainnya, Qibla, Qur'an entry points).
- Keep UI behavior unchanged; this spec is icon-only.
## Inventory Summary (Current State)
- Total Lucide references: `158`
- Unique Lucide symbols: `72`
- Non-Lucid icon reference: `Icons.arrow_back` in `quran_murattal_screen.dart`
Top icon-heavy files:
1. `lib/features/settings/presentation/settings_screen.dart`
2. `lib/features/dashboard/presentation/dashboard_screen.dart`
3. `lib/features/quran/presentation/quran_reading_screen.dart`
4. `lib/features/checklist/presentation/checklist_screen.dart`
5. `lib/features/tools/presentation/tools_screen.dart`
## Recommended HugeIcons Style Policy
- `Stroke Rounded` for navigation/system icons.
- `Duotone Rounded` for high-emphasis feature cards.
- Islamic-specific icons use explicit semantic slugs (Qur'an, Tasbih, Kaaba, Dua, Mosque).
## Canonical Mapping (Lucide -> HugeIcons Candidate)
Notes:
- `Confidence` = how certain the target slug/semantic fit is.
- `High` means already validated semantically on HugeIcons.
- `Medium` means strong candidate but should be quickly verified in final implementation pass.
| Lucide | HugeIcons candidate | Confidence | Notes |
|---|---|---|---|
| `home` | `home-01-stroke-rounded` | High | Bottom nav Beranda |
| `calendar` | `calendar-01` | High | Bottom nav Jadwal |
| `bookOpen` | `quran-02-solid-sharp` (feature), `book-open-01-stroke-rounded` (generic) | High | Use Qur'an variant where context is Qur'an |
| `sparkles` | `tasbih` (Dzikir), `sparkles`-family for generic | High/Medium | Dzikir should use Tasbih |
| `wand2` | `magic-wand-01-duotone-rounded` | High | Bottom nav Lainnya |
| `listChecks` | `check-list` | High | Ibadah list |
| `barChart3` | `bar-chart-03-stroke-rounded` | High | Laporan |
| `headphones` | `headset-solid-rounded` | Medium | Murattal/audio |
| `compass` | `compass-duotone-rounded` | High | Qibla entry/action |
| `library` | `books-01-solid-standard` | High | Hadits card |
| `heart` | `dua-solid-sharp` (Doa context), `heart`-family (generic) | High/Medium | Prefer Dua icon for Doa feature |
| `mapPin` | `mosque-location` (Qibla/location context), `location-01` (generic) | High/Medium | Prefer mosque-location in Islamic context |
| `locate` | `compass-duotone-rounded` or `location-focus`-family | Medium | Qibla live-state |
| `locateOff` | `location-off`-family | Medium | Qibla sensor-off |
| `bookmark` | `bookmark-02-stroke-rounded` | Medium | Qur'an/bookmarks |
| `pin` | `pin-location`-family | Medium | Last read |
| `search` | `search-01-stroke-rounded` | Medium | Global search |
| `refreshCw` | `refresh`-family | Medium | Reload actions |
| `settings` | `settings-02-stroke-rounded` | Medium | Global settings |
| `settings2` | `settings-02-stroke-rounded` | Medium | Keep one settings variant |
| `bell` | `notification`-family | Medium | Notification |
| `checkCircle2` | `checkmark-circle`-family | Medium | Completion status |
| `circle` | `circle`-family | Medium | Unchecked state |
| `check` | `tick`-family | Medium | Confirmed state |
| `chevronLeft` | `arrow-left-01-stroke-rounded` | Medium | Back |
| `chevronRight` | `arrow-right-01-stroke-rounded` | Medium | Settings rows |
| `chevronDown` | `arrow-down-01-stroke-rounded` | Medium | Dropdown |
| `chevronUp` | `arrow-up-01-stroke-rounded` | Medium | Expand/collapse |
| `arrowLeft` | `arrow-left-01-stroke-rounded` | Medium | Back |
| `arrowRight` | `arrow-right-01-stroke-rounded` | Medium | Forward |
| `clock` | `time`-family | Medium | Prayer/meta time |
| `sunrise` | `sunrise`-family | Medium | Prayer icon |
| `sun` | `sun`-family | Medium | Prayer icon |
| `cloudSun` | `cloud-sun`-family | Medium | Prayer icon |
| `sunset` | `sunset`-family | Medium | Prayer icon |
| `moon` | `moon`-family | Medium | Prayer/night |
| `volume2` | `volume-high`-family | Medium | Audio toggle |
| `user` | `user-03-stroke-rounded` | Medium | Profile/author |
| `quote` | `quote`-family | Medium | Ayat quote panel |
| `fingerprint` | `fingerprint-01` | Medium | Dzikir tap counter |
| `inbox` | `inbox-01` | Medium | Empty state |
| `wifiOff` | `wifi-off-01` | Medium | Offline state |
| `star` | `star`-family | Medium | Ratings/highlight |
| `building` | `mosque-01` (Islamic), `building`-family (generic) | Medium | Prefer mosque if religious place context |
| `minusCircle` | `minus-sign-circle`-family | Medium | Counter controls |
| `plusCircle` | `plus-sign-circle`-family | Medium | Counter controls |
| `moonStar` | `moon-stars`-family | Medium | Night checklist |
| `trendingUp` | `chart-up`-family | Medium | Trend KPI |
| `history` | `history`-family | Medium | Reports history |
| `share2` | `share`-family | Medium | Share action |
| `play` | `play`-family | Medium | Media |
| `pause` | `pause`-family | Medium | Media |
| `stopCircle` | `stop-circle`-family | Medium | Media/record stop |
| `playCircle` | `play-circle`-family | Medium | Media/record play |
| `square` | `stop-square`-family | Medium | Media |
| `skipBack` | `skip-back`-family | Medium | Media |
| `skipForward` | `skip-forward`-family | Medium | Media |
| `shuffle` | `shuffle`-family | Medium | Media |
| `listMusic` | `playlist`-family | Medium | Media list |
| `music` | `music-note`-family | Medium | Media indicator |
| `brain` | `brain`-family | Medium | Hafalan mode |
| `gem` | `diamond`/`gem`-family | Medium | Highlight badge |
| `flag` | `flag`-family | Medium | Marker |
| `trash2` | `delete`-family | Medium | Remove bookmark |
| `layoutDashboard` | `dashboard-square`-family | Medium | Settings |
| `timer` | `timer`-family | Medium | Timing settings |
| `type` | `text`-family | Medium | Typography settings |
| `vibrate` | `vibration`-family | Medium | Haptic settings |
| `pencil` | `edit`-family | Medium | Edit profile |
| `info` | `information-circle`-family | Medium | Info |
| `logOut` | `logout-01`-family | Medium | Logout |
| `languages` | `language`-family | Medium | Qur'an enrichment language |
## Screen-Level Migration Targets
### P0: Bottom Navigation
File: `lib/core/widgets/bottom_nav_bar.dart`
- `home` -> `home-01-stroke-rounded`
- `calendar` -> `calendar-01`
- `bookOpen` -> `quran-02-solid-sharp`
- `sparkles` -> `tasbih`
- `wand2` -> `magic-wand-01-duotone-rounded`
- `listChecks` -> `check-list`
- `barChart3` -> `bar-chart-03-stroke-rounded`
### P0: Beranda Quick Access + Header Context
File: `lib/features/dashboard/presentation/dashboard_screen.dart`
- Qur'an card: `bookOpen` -> `quran-02-solid-sharp`
- Murattal: `headphones` -> `headset-solid-rounded`
- Dzikir: `sparkles` -> `tasbih`
- Doa: `heart` -> `dua-solid-sharp`
- Hadits: `library` -> `books-01-solid-standard`
- Qibla button/location: `compass/mapPin` -> `compass-duotone-rounded` + `mosque-location`
- Prayer state icons (`sunrise/sun/cloudSun/sunset/moon`) -> matching HugeIcons weather/time family
### P0: Lainnya (Tools)
File: `lib/features/tools/presentation/tools_screen.dart`
- Same feature mapping as Beranda quick access.
- App-bar/system icons remain generic HugeIcons stroke-rounded (`bell`, `settings`, `share`).
### P0: Qibla Screen
File: `lib/features/qibla/presentation/qibla_screen.dart`
- Back: `arrowLeft` -> `arrow-left-01-stroke-rounded`
- Live state: `locate/locateOff` -> location-focus/location-off family
- Location marker: `mapPin` -> `mosque-location` (preferred) or `location-01`
### P1: Qur'an, Dzikir, Doa, Hadits
- Qur'an family files: prioritize Qur'an/bookmark/media semantics.
- Dzikir screen: `fingerprint` may remain if preferred UX metaphor; alternative `tasbih` for stronger Islamic tone.
- Doa/Hadits list screens: keep simple system icon replacements for `search`, `refresh`.
### P2: Settings / Checklist / Laporan
- Mostly generic system icons; migrate to HugeIcons equivalents with minimal semantic risk.
## Proposed Implementation Contract
1. Introduce wrapper layer (single source of truth): `lib/app/icons/app_icons.dart`
2. Keep semantic names stable in app code (`AppIcons.quran`, `AppIcons.dzikir`, `AppIcons.qibla`, etc.).
3. Map `AppIcons.*` to HugeIcons package symbols.
4. Perform migration by feature slices (P0 -> P2), with visual QA after each slice.
## QA Checklist
- Bottom nav recognizability retained at 24dp.
- Active/inactive contrast unchanged.
- No icon clipping at 20/24dp sizes.
- Islamic features feel more explicit (Qur'an, Dzikir, Doa, Qibla).
- Dark/light mode visual consistency maintained.
## Reference Links (HugeIcons)
- https://hugeicons.com/icon/quran-02-solid-sharp
- https://hugeicons.com/icon/tasbih
- https://hugeicons.com/icon/mosque-location
- https://hugeicons.com/icon/kaaba-01-stroke-standard
- https://hugeicons.com/icon/dua-solid-sharp
- https://hugeicons.com/icon/books-01-solid-standard
- https://hugeicons.com/icon/home-01-stroke-rounded
- https://hugeicons.com/icon/calendar-01
- https://hugeicons.com/icon/check-list
- https://hugeicons.com/icon/magic-wand-01-duotone-rounded
- https://hugeicons.com/icon/bar-chart-03-stroke-rounded
- https://hugeicons.com/icon/compass-duotone-rounded
- https://hugeicons.com/icon/headset-solid-rounded

34
ios/.gitignore vendored Normal file
View File

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

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>App</string>
<key>CFBundleIdentifier</key>
<string>io.flutter.flutter.app</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>App</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
</dict>
</plist>

View File

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

View File

@@ -1,6 +1,6 @@
// This is a generated file; do not edit or check into version control.
FLUTTER_ROOT=/Users/dwindown/FlutterDev/flutter
FLUTTER_APPLICATION_PATH=/Users/dwindown/CascadeProjects/jamshalat-diary
FLUTTER_ROOT=/opt/homebrew/share/flutter
FLUTTER_APPLICATION_PATH=/Users/dwindown/Applications/jamshalat-diary
COCOAPODS_PARALLEL_CODE_SIGN=true
FLUTTER_TARGET=lib/main.dart
FLUTTER_BUILD_DIR=build

View File

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

View File

@@ -1,7 +1,7 @@
#!/bin/sh
# This is a generated file; do not edit or check into version control.
export "FLUTTER_ROOT=/Users/dwindown/FlutterDev/flutter"
export "FLUTTER_APPLICATION_PATH=/Users/dwindown/CascadeProjects/jamshalat-diary"
export "FLUTTER_ROOT=/opt/homebrew/share/flutter"
export "FLUTTER_APPLICATION_PATH=/Users/dwindown/Applications/jamshalat-diary"
export "COCOAPODS_PARALLEL_CODE_SIGN=true"
export "FLUTTER_TARGET=lib/main.dart"
export "FLUTTER_BUILD_DIR=build"

View File

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

View File

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

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>

View File

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

View File

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

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

View File

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

View File

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

View File

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

View File

@@ -60,6 +60,12 @@
@import package_info_plus;
#endif
#if __has_include(<share_plus/FPPSharePlusPlugin.h>)
#import <share_plus/FPPSharePlusPlugin.h>
#else
@import share_plus;
#endif
#if __has_include(<sqflite_darwin/SqflitePlugin.h>)
#import <sqflite_darwin/SqflitePlugin.h>
#else
@@ -84,6 +90,7 @@
[GeolocatorPlugin registerWithRegistrar:[registry registrarForPlugin:@"GeolocatorPlugin"]];
[JustAudioPlugin registerWithRegistrar:[registry registrarForPlugin:@"JustAudioPlugin"]];
[FPPPackageInfoPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPPackageInfoPlusPlugin"]];
[FPPSharePlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPSharePlusPlugin"]];
[SqflitePlugin registerWithRegistrar:[registry registrarForPlugin:@"SqflitePlugin"]];
[URLLauncherPlugin registerWithRegistrar:[registry registrarForPlugin:@"URLLauncherPlugin"]];
}

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

@@ -0,0 +1,74 @@
<?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 Diary</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_diary</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>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>

View File

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

View File

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

View File

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

View File

@@ -1,16 +1,80 @@
import 'dart:async';
import 'dart:ui' show ViewFocusEvent, ViewFocusState;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/providers/theme_provider.dart';
import '../features/dashboard/data/prayer_times_provider.dart';
import 'router.dart';
import 'theme/app_theme.dart';
/// Root MaterialApp.router wired to GoRouter + ThemeMode from Riverpod.
class App extends ConsumerWidget {
class App extends ConsumerStatefulWidget {
const App({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
ConsumerState<App> createState() => _AppState();
}
class _AppState extends ConsumerState<App> with WidgetsBindingObserver {
Timer? _midnightResyncTimer;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
WidgetsBinding.instance.addPostFrameCallback((_) {
HardwareKeyboard.instance.syncKeyboardState();
});
_scheduleMidnightResync();
}
@override
void dispose() {
_midnightResyncTimer?.cancel();
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed ||
state == AppLifecycleState.inactive) {
// Resync stale pressed-key state to avoid repeated KeyDown assertions.
HardwareKeyboard.instance.syncKeyboardState();
}
if (state == AppLifecycleState.resumed) {
ref.invalidate(prayerTimesProvider);
unawaited(ref.read(prayerTimesProvider.future));
_scheduleMidnightResync();
}
}
@override
void didChangeViewFocus(ViewFocusEvent event) {
if (event.state == ViewFocusState.focused) {
HardwareKeyboard.instance.syncKeyboardState();
}
}
void _scheduleMidnightResync() {
_midnightResyncTimer?.cancel();
final now = DateTime.now();
final nextRun = DateTime(now.year, now.month, now.day, 0, 5).isAfter(now)
? DateTime(now.year, now.month, now.day, 0, 5)
: DateTime(now.year, now.month, now.day + 1, 0, 5);
final delay = nextRun.difference(now);
_midnightResyncTimer = Timer(delay, () {
ref.invalidate(prayerTimesProvider);
unawaited(ref.read(prayerTimesProvider.future));
_scheduleMidnightResync();
});
}
@override
Widget build(BuildContext context) {
final themeMode = ref.watch(themeProvider);
return MaterialApp.router(

View File

@@ -0,0 +1,119 @@
import 'package:flutter/material.dart';
import 'package:hugeicons/hugeicons.dart';
@immutable
class AppIconGlyph {
const AppIconGlyph.material(this.material) : huge = null;
const AppIconGlyph.huge(this.huge) : material = null;
final IconData? material;
final List<List<dynamic>>? huge;
}
class AppIcon extends StatelessWidget {
const AppIcon({
super.key,
required this.glyph,
this.color,
this.size,
this.strokeWidth,
this.semanticLabel,
});
final AppIconGlyph glyph;
final Color? color;
final double? size;
final double? strokeWidth;
final String? semanticLabel;
@override
Widget build(BuildContext context) {
final huge = glyph.huge;
if (huge != null) {
return HugeIcon(
icon: huge,
color: color,
size: size,
strokeWidth: strokeWidth,
);
}
return Icon(
glyph.material,
color: color,
size: size,
semanticLabel: semanticLabel,
);
}
}
class AppIcons {
const AppIcons._();
static const AppIconGlyph home =
AppIconGlyph.huge(HugeIcons.strokeRoundedHome01);
static const AppIconGlyph calendar =
AppIconGlyph.huge(HugeIcons.strokeRoundedCalendar01);
static const AppIconGlyph quran =
AppIconGlyph.huge(HugeIcons.strokeRoundedQuran02);
static const AppIconGlyph dzikir =
AppIconGlyph.huge(HugeIcons.strokeRoundedTasbih);
static const AppIconGlyph lainnya =
AppIconGlyph.huge(HugeIcons.strokeRoundedGridView);
static const AppIconGlyph ibadah =
AppIconGlyph.huge(HugeIcons.strokeRoundedCheckList);
static const AppIconGlyph laporan =
AppIconGlyph.huge(HugeIcons.strokeRoundedChart01);
static const AppIconGlyph murattal =
AppIconGlyph.huge(HugeIcons.strokeRoundedHeadset);
static const AppIconGlyph qibla =
AppIconGlyph.huge(HugeIcons.strokeRoundedCompass);
static const AppIconGlyph doa = AppIconGlyph.huge(HugeIcons.strokeRoundedDua);
static const AppIconGlyph hadits =
AppIconGlyph.huge(HugeIcons.strokeRoundedBooks01);
static const AppIconGlyph quranEnrichment =
AppIconGlyph.huge(HugeIcons.strokeRoundedQuran01);
static const AppIconGlyph notification =
AppIconGlyph.huge(HugeIcons.strokeRoundedNotification03);
static const AppIconGlyph settings =
AppIconGlyph.huge(HugeIcons.strokeRoundedSettings02);
static const AppIconGlyph share =
AppIconGlyph.huge(HugeIcons.strokeRoundedShare01);
static const AppIconGlyph themeMoon =
AppIconGlyph.huge(HugeIcons.strokeRoundedMoon02);
static const AppIconGlyph themeSun =
AppIconGlyph.huge(HugeIcons.strokeRoundedSun02);
static const AppIconGlyph checkCircle =
AppIconGlyph.huge(HugeIcons.strokeRoundedCheckmarkCircle02);
static const AppIconGlyph circle =
AppIconGlyph.huge(HugeIcons.strokeRoundedCircle);
static const AppIconGlyph musicNote =
AppIconGlyph.huge(HugeIcons.strokeRoundedMusicNote01);
static const AppIconGlyph shuffle =
AppIconGlyph.huge(HugeIcons.strokeRoundedShuffle);
static const AppIconGlyph previousTrack =
AppIconGlyph.huge(HugeIcons.strokeRoundedPrevious);
static const AppIconGlyph nextTrack =
AppIconGlyph.huge(HugeIcons.strokeRoundedNext);
static const AppIconGlyph play =
AppIconGlyph.huge(HugeIcons.strokeRoundedPlay);
static const AppIconGlyph pause =
AppIconGlyph.huge(HugeIcons.strokeRoundedPause);
static const AppIconGlyph playlist =
AppIconGlyph.huge(HugeIcons.strokeRoundedPlaylist01);
static const AppIconGlyph user =
AppIconGlyph.huge(HugeIcons.strokeRoundedUser03);
static const AppIconGlyph arrowDown =
AppIconGlyph.huge(HugeIcons.strokeRoundedArrowDown01);
static const AppIconGlyph backArrow =
AppIconGlyph.huge(HugeIcons.strokeRoundedArrowLeft01);
static const AppIconGlyph location =
AppIconGlyph.huge(HugeIcons.strokeRoundedMosqueLocation);
static const AppIconGlyph locationActive =
AppIconGlyph.huge(HugeIcons.strokeRoundedLocation01);
static const AppIconGlyph locationOffline =
AppIconGlyph.huge(HugeIcons.strokeRoundedLocationOffline01);
}

View File

@@ -1,4 +1,6 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:go_router/go_router.dart';
import 'package:hive_flutter/hive_flutter.dart';
import '../data/local/hive_boxes.dart';
@@ -11,11 +13,15 @@ import '../features/checklist/presentation/checklist_screen.dart';
import '../features/laporan/presentation/laporan_screen.dart';
import '../features/tools/presentation/tools_screen.dart';
import '../features/dzikir/presentation/dzikir_screen.dart';
import '../features/doa/presentation/doa_screen.dart';
import '../features/hadits/presentation/hadits_screen.dart';
import '../features/qibla/presentation/qibla_screen.dart';
import '../features/quran/presentation/quran_screen.dart';
import '../features/quran/presentation/quran_reading_screen.dart';
import '../features/quran/presentation/quran_murattal_screen.dart';
import '../features/quran/presentation/quran_bookmarks_screen.dart';
import '../features/quran/presentation/quran_enrichment_screen.dart';
import '../features/notifications/presentation/notification_center_screen.dart';
import '../features/settings/presentation/settings_screen.dart';
/// Navigation key for the shell navigator (bottom-nav screens).
@@ -30,12 +36,16 @@ final GoRouter appRouter = GoRouter(
// ── Shell route (bottom nav persists) ──
ShellRoute(
navigatorKey: _shellNavigatorKey,
builder: (context, state, child) => _ScaffoldWithNav(child: child),
pageBuilder: (context, state, child) => NoTransitionPage(
key: ValueKey<String>('shell-${state.pageKey.value}'),
child: _ScaffoldWithNav(child: child),
),
routes: [
GoRoute(
path: '/',
pageBuilder: (context, state) => const NoTransitionPage(
child: DashboardScreen(),
pageBuilder: (context, state) => NoTransitionPage(
key: state.pageKey,
child: const DashboardScreen(),
),
routes: [
GoRoute(
@@ -47,26 +57,30 @@ final GoRouter appRouter = GoRouter(
),
GoRoute(
path: '/imsakiyah',
pageBuilder: (context, state) => const NoTransitionPage(
child: ImsakiyahScreen(),
pageBuilder: (context, state) => NoTransitionPage(
key: state.pageKey,
child: const ImsakiyahScreen(),
),
),
GoRoute(
path: '/checklist',
pageBuilder: (context, state) => const NoTransitionPage(
child: ChecklistScreen(),
pageBuilder: (context, state) => NoTransitionPage(
key: state.pageKey,
child: const ChecklistScreen(),
),
),
GoRoute(
path: '/laporan',
pageBuilder: (context, state) => const NoTransitionPage(
child: LaporanScreen(),
pageBuilder: (context, state) => NoTransitionPage(
key: state.pageKey,
child: const LaporanScreen(),
),
),
GoRoute(
path: '/tools',
pageBuilder: (context, state) => const NoTransitionPage(
child: ToolsScreen(),
pageBuilder: (context, state) => NoTransitionPage(
key: state.pageKey,
child: const ToolsScreen(),
),
routes: [
GoRoute(
@@ -79,6 +93,11 @@ final GoRouter appRouter = GoRouter(
parentNavigatorKey: _rootNavigatorKey,
builder: (context, state) => const QuranScreen(),
routes: [
GoRoute(
path: 'enrichment',
parentNavigatorKey: _rootNavigatorKey,
builder: (context, state) => const QuranEnrichmentScreen(),
),
GoRoute(
path: 'bookmarks',
parentNavigatorKey: _rootNavigatorKey,
@@ -89,8 +108,10 @@ final GoRouter appRouter = GoRouter(
parentNavigatorKey: _rootNavigatorKey,
builder: (context, state) {
final surahId = state.pathParameters['surahId']!;
final startVerse = int.tryParse(state.uri.queryParameters['startVerse'] ?? '');
return QuranReadingScreen(surahId: surahId, initialVerse: startVerse);
final startVerse = int.tryParse(
state.uri.queryParameters['startVerse'] ?? '');
return QuranReadingScreen(
surahId: surahId, initialVerse: startVerse);
},
routes: [
GoRoute(
@@ -99,7 +120,8 @@ final GoRouter appRouter = GoRouter(
builder: (context, state) {
final surahId = state.pathParameters['surahId']!;
final qariId = state.uri.queryParameters['qariId'];
final autoplay = state.uri.queryParameters['autoplay'] == 'true';
final autoplay =
state.uri.queryParameters['autoplay'] == 'true';
return QuranMurattalScreen(
surahId: surahId,
initialQariId: qariId,
@@ -116,28 +138,49 @@ final GoRouter appRouter = GoRouter(
parentNavigatorKey: _rootNavigatorKey,
builder: (context, state) => const QiblaScreen(),
),
GoRoute(
path: 'doa',
parentNavigatorKey: _rootNavigatorKey,
builder: (context, state) => const DoaScreen(),
),
GoRoute(
path: 'hadits',
parentNavigatorKey: _rootNavigatorKey,
builder: (context, state) => const HaditsScreen(),
),
],
),
// Simple Mode Tab: Zikir
GoRoute(
path: '/dzikir',
builder: (context, state) => const DzikirScreen(isSimpleModeTab: true),
builder: (context, state) =>
const DzikirScreen(isSimpleModeTab: true),
),
// Simple Mode Tab: Tilawah
GoRoute(
path: '/quran',
builder: (context, state) => const QuranScreen(isSimpleModeTab: true),
routes: [
GoRoute(
path: 'enrichment',
builder: (context, state) =>
const QuranEnrichmentScreen(isSimpleModeTab: true),
),
GoRoute(
path: 'bookmarks',
builder: (context, state) => const QuranBookmarksScreen(),
builder: (context, state) =>
const QuranBookmarksScreen(isSimpleModeTab: true),
),
GoRoute(
path: ':surahId',
builder: (context, state) {
final surahId = state.pathParameters['surahId']!;
final startVerse = int.tryParse(state.uri.queryParameters['startVerse'] ?? '');
return QuranReadingScreen(surahId: surahId, initialVerse: startVerse, isSimpleModeTab: true);
final startVerse =
int.tryParse(state.uri.queryParameters['startVerse'] ?? '');
return QuranReadingScreen(
surahId: surahId,
initialVerse: startVerse,
isSimpleModeTab: true);
},
routes: [
GoRoute(
@@ -146,7 +189,8 @@ final GoRouter appRouter = GoRouter(
builder: (context, state) {
final surahId = state.pathParameters['surahId']!;
final qariId = state.uri.queryParameters['qariId'];
final autoplay = state.uri.queryParameters['autoplay'] == 'true';
final autoplay =
state.uri.queryParameters['autoplay'] == 'true';
return QuranMurattalScreen(
surahId: surahId,
initialQariId: qariId,
@@ -159,9 +203,23 @@ final GoRouter appRouter = GoRouter(
),
],
),
GoRoute(
path: '/doa',
builder: (context, state) => const DoaScreen(isSimpleModeTab: true),
),
GoRoute(
path: '/hadits',
builder: (context, state) =>
const HaditsScreen(isSimpleModeTab: true),
),
],
),
// ── Settings (pushed, no bottom nav) ──
GoRoute(
path: '/notifications',
parentNavigatorKey: _rootNavigatorKey,
builder: (context, state) => const NotificationCenterScreen(),
),
GoRoute(
path: '/settings',
parentNavigatorKey: _rootNavigatorKey,
@@ -171,11 +229,31 @@ final GoRouter appRouter = GoRouter(
);
/// Scaffold wrapper that provides the persistent bottom nav bar.
class _ScaffoldWithNav extends StatelessWidget {
class _ScaffoldWithNav extends StatefulWidget {
const _ScaffoldWithNav({required this.child});
final Widget child;
@override
State<_ScaffoldWithNav> createState() => _ScaffoldWithNavState();
}
class _ScaffoldWithNavState extends State<_ScaffoldWithNav> {
DateTime? _lastBackPressedAt;
bool _shouldHideBottomNav({
required bool isSimpleMode,
required String path,
}) {
if (!isSimpleMode) return false;
if (path == '/dzikir') return true;
if (!path.startsWith('/quran/')) return false;
final tail = path.substring('/quran/'.length);
if (tail == 'bookmarks' || tail == 'enrichment') return false;
return !tail.contains('/');
}
/// Maps route locations to bottom nav indices.
int _currentIndex(BuildContext context) {
final location = GoRouterState.of(context).uri.toString();
@@ -184,9 +262,13 @@ class _ScaffoldWithNav extends StatelessWidget {
if (isSimpleMode) {
if (location.startsWith('/imsakiyah')) return 1;
if (location.startsWith('/quran') && !location.contains('/murattal')) return 2;
if (location.contains('/murattal')) return 3;
if (location.startsWith('/dzikir')) return 4;
if (location.startsWith('/quran')) return 2;
if (location.startsWith('/dzikir')) return 3;
if (location.startsWith('/tools') ||
location.startsWith('/doa') ||
location.startsWith('/hadits')) {
return 4;
}
return 0;
} else {
if (location.startsWith('/imsakiyah')) return 1;
@@ -213,10 +295,10 @@ class _ScaffoldWithNav extends StatelessWidget {
context.go('/quran');
break;
case 3:
context.push('/quran/1/murattal');
context.go('/dzikir');
break;
case 4:
context.go('/dzikir');
context.go('/tools');
break;
}
} else {
@@ -240,17 +322,98 @@ class _ScaffoldWithNav extends StatelessWidget {
}
}
bool _isMainShellRoute({
required bool isSimpleMode,
required String path,
}) {
if (isSimpleMode) {
return path == '/' ||
path == '/imsakiyah' ||
path == '/quran' ||
path == '/dzikir' ||
path == '/tools';
}
return path == '/' ||
path == '/imsakiyah' ||
path == '/checklist' ||
path == '/laporan' ||
path == '/tools';
}
Future<void> _handleMainRouteBack(
BuildContext context, {
required String path,
}) async {
if (path != '/') {
context.go('/');
return;
}
final now = DateTime.now();
final pressedRecently = _lastBackPressedAt != null &&
now.difference(_lastBackPressedAt!) <= const Duration(seconds: 2);
if (pressedRecently) {
await SystemNavigator.pop();
return;
}
_lastBackPressedAt = now;
final messenger = ScaffoldMessenger.maybeOf(context);
messenger
?..hideCurrentSnackBar()
..showSnackBar(
const SnackBar(
content: Text('Tekan sekali lagi untuk keluar'),
duration: Duration(seconds: 2),
),
);
}
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<Box<AppSettings>>(
valueListenable: Hive.box<AppSettings>(HiveBoxes.settings).listenable(),
builder: (context, box, _) {
return Scaffold(
body: child,
bottomNavigationBar: AppBottomNavBar(
final isSimpleMode = box.get('default')?.simpleMode ?? false;
final path = GoRouterState.of(context).uri.path;
final hideBottomNav = _shouldHideBottomNav(
isSimpleMode: isSimpleMode,
path: path,
);
final modeScopedChild = KeyedSubtree(
key: ValueKey(
'shell:$path:${isSimpleMode ? 'simple' : 'full'}',
),
child: widget.child,
);
final pageBody = hideBottomNav
? SafeArea(
top: false,
child: modeScopedChild,
)
: modeScopedChild;
final handleBackInShell =
defaultTargetPlatform == TargetPlatform.android &&
_isMainShellRoute(isSimpleMode: isSimpleMode, path: path);
return PopScope(
canPop: !handleBackInShell,
onPopInvokedWithResult: (didPop, _) async {
if (didPop || !handleBackInShell) return;
await _handleMainRouteBack(context, path: path);
},
child: Scaffold(
body: pageBody,
bottomNavigationBar: hideBottomNav
? null
: AppBottomNavBar(
currentIndex: _currentIndex(context),
onTap: (i) => _onTap(context, i),
),
),
);
},
);

View File

@@ -4,29 +4,55 @@ import 'package:flutter/material.dart';
class AppColors {
AppColors._();
// ── Primary ──
static const Color primary = Color(0xFF70DF20);
static const Color onPrimary = Color(0xFF0A1A00);
// ── Brand tokens: logo palette (teal + gold) ──
static const Color brandTeal500 = Color(0xFF118A8D);
static const Color brandTeal700 = Color(0xFF0C676A);
static const Color brandTeal900 = Color(0xFF0A4447);
// ── Background ──
static const Color backgroundLight = Color(0xFFF7F8F6);
static const Color backgroundDark = Color(0xFF182111);
static const Color brandGold200 = Color(0xFFF6DE96);
static const Color brandGold300 = Color(0xFFE9C75B);
static const Color brandGold400 = Color(0xFFD6A21D);
static const Color brandGold700 = Color(0xFF8B6415);
// ── Theme base tokens ──
static const Color backgroundLight = Color(0xFFF3F4F6);
static const Color backgroundDark = Color(0xFF0F1217);
// ── Surface ──
static const Color surfaceLight = Color(0xFFFFFFFF);
static const Color surfaceDark = Color(0xFF1E2A14);
static const Color surfaceLightElevated = Color(0xFFF9FAFB);
// ── Sage (secondary text / section labels) ──
static const Color sage = Color(0xFF728764);
static const Color surfaceDark = Color(0xFF171B22);
static const Color surfaceDarkElevated = Color(0xFF1D222B);
// ── Cream (dividers, borders — light mode only) ──
static const Color cream = Color(0xFFF2F4F0);
static const Color textPrimaryLight = Color(0xFF1F2937);
static const Color textPrimaryDark = Color(0xFFE8ECF2);
static const Color textSecondaryLight = Color(0xFF6B7280);
static const Color textSecondaryDark = Color(0xFF9AA4B2);
// ── Text ──
static const Color textPrimaryLight = Color(0xFF1A2A0A);
static const Color textPrimaryDark = Color(0xFFF2F4F0);
static const Color textSecondaryLight = Color(0xFF64748B);
static const Color textSecondaryDark = Color(0xFF94A3B8);
// ── Compatibility aliases (existing UI references) ──
static const Color primary = brandTeal500;
static const Color onPrimary = Color(0xFFFFFFFF);
static const Color sage = brandTeal700;
static const Color cream = Color(0xFFE5E7EB);
// ── Luxury active-state tokens ──
static const Color navActiveGold = brandGold400;
static const Color navActiveGoldBright = brandGold300;
static const Color navActiveGoldPale = brandGold200;
static const Color navActiveGoldDeep = brandGold700;
static const Color navActiveSurfaceDark = surfaceDarkElevated;
static const Color navActiveSurfaceLight = surfaceLight;
static const Color navGlowDark = Color(0x5CD6A21D);
static const Color navGlowLight = Color(0x36D6A21D);
static const Color navShadowLight = Color(0x1F0F172A);
static const Color navStrokeNeutralDark = Color(0x33FFFFFF);
static const Color navStrokeNeutralLight = Color(0x220F172A);
static const Color navEmbossHighlight = Color(0xE6FFFFFF);
static const Color navEmbossShadow = Color(0x2B0F172A);
static const Color navEmbossGoldShadow = Color(0x42B88912);
// ── Semantic ──
static const Color errorLight = Color(0xFFEF4444);
@@ -36,25 +62,29 @@ class AppColors {
// ── Convenience helpers for theme building ──
static ColorScheme get lightColorScheme => ColorScheme.light(
static ColorScheme get lightColorScheme => const ColorScheme.light(
primary: primary,
onPrimary: onPrimary,
primaryContainer: brandTeal700,
onPrimaryContainer: Colors.white,
surface: surfaceLight,
onSurface: textPrimaryLight,
error: errorLight,
onError: Colors.white,
secondary: sage,
onSecondary: Colors.white,
secondary: navActiveGold,
onSecondary: brandGold700,
);
static ColorScheme get darkColorScheme => ColorScheme.dark(
static ColorScheme get darkColorScheme => const ColorScheme.dark(
primary: primary,
onPrimary: onPrimary,
primaryContainer: brandTeal900,
onPrimaryContainer: textPrimaryDark,
surface: surfaceDark,
onSurface: textPrimaryDark,
error: errorDark,
onError: Colors.black,
secondary: sage,
onSecondary: Colors.white,
secondary: navActiveGold,
onSecondary: brandGold200,
);
}

View File

@@ -1,11 +1,22 @@
import 'package:flutter/material.dart';
/// Typography definitions from PRD §3.2.
/// Plus Jakarta Sans (bundled) for UI text, Amiri (bundled) for Arabic content.
/// Plus Jakarta Sans (bundled) for UI text.
/// Scheherazade New (bundled) for Arabic/Quran text, with Uthman/KFGQPC fallback.
class AppTextStyles {
AppTextStyles._();
static const String _fontFamily = 'PlusJakartaSans';
static const String _arabicFontFamily = 'ScheherazadeNew';
static const List<String> _arabicFallbackFamilies = <String>[
'UthmanTahaNaskh',
'KFGQPCUthmanicHafs',
'Amiri',
'Noto Naskh Arabic',
'Noto Sans Arabic',
'Droid Arabic Naskh',
'sans-serif',
];
/// Builds the full TextTheme for the app using bundled Plus Jakarta Sans.
static const TextTheme textTheme = TextTheme(
@@ -52,19 +63,21 @@ class AppTextStyles {
),
);
// ── Arabic text styles (Amiri — bundled font) ──
// ── Arabic text styles (Scheherazade New — bundled font) ──
static const TextStyle arabicBody = TextStyle(
fontFamily: 'Amiri',
fontFamily: _arabicFontFamily,
fontFamilyFallback: _arabicFallbackFamilies,
fontSize: 24,
fontWeight: FontWeight.w400,
height: 2.0,
height: 1.8,
);
static const TextStyle arabicLarge = TextStyle(
fontFamily: 'Amiri',
fontFamily: _arabicFontFamily,
fontFamilyFallback: _arabicFallbackFamilies,
fontSize: 28,
fontWeight: FontWeight.w700,
height: 2.2,
fontWeight: FontWeight.w400,
height: 2.0,
);
}

View File

@@ -29,17 +29,17 @@ class AppTheme {
),
bottomNavigationBarTheme: const BottomNavigationBarThemeData(
backgroundColor: AppColors.surfaceLight,
selectedItemColor: AppColors.primary,
selectedItemColor: AppColors.navActiveGoldDeep,
unselectedItemColor: AppColors.textSecondaryLight,
type: BottomNavigationBarType.fixed,
elevation: 0,
),
cardTheme: CardThemeData(
color: AppColors.surfaceLight,
color: AppColors.surfaceLightElevated,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
side: const BorderSide(
color: AppColors.cream,
),
),
@@ -70,21 +70,21 @@ class AppTheme {
),
bottomNavigationBarTheme: const BottomNavigationBarThemeData(
backgroundColor: AppColors.surfaceDark,
selectedItemColor: AppColors.primary,
selectedItemColor: AppColors.navActiveGold,
unselectedItemColor: AppColors.textSecondaryDark,
type: BottomNavigationBarType.fixed,
elevation: 0,
),
cardTheme: CardThemeData(
color: AppColors.surfaceDark,
color: AppColors.surfaceDarkElevated,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: AppColors.primary.withValues(alpha: 0.1),
color: AppColors.brandTeal500.withValues(alpha: 0.22),
),
),
),
dividerColor: AppColors.surfaceDark,
dividerColor: AppColors.surfaceDarkElevated,
);
}

View File

@@ -0,0 +1,11 @@
import 'package:just_audio/just_audio.dart';
/// Shared app-wide audio player.
///
/// `just_audio_background` supports only one `AudioPlayer` instance, so all
/// playback surfaces should reuse this singleton.
class AppAudioPlayer {
AppAudioPlayer._();
static final AudioPlayer instance = AudioPlayer();
}

View File

@@ -0,0 +1,94 @@
import 'package:flutter/material.dart';
import 'package:hive_flutter/hive_flutter.dart';
import '../../data/local/hive_boxes.dart';
import '../../data/local/models/app_settings.dart';
/// Arabic text widget that reacts to [AppSettings.arabicFontSize].
///
/// `baseFontSize` keeps per-screen visual hierarchy while still following
/// global user preference from Settings.
class ArabicText extends StatelessWidget {
const ArabicText(
this.data, {
super.key,
this.baseFontSize = 24,
this.fontWeight = FontWeight.w400,
this.height,
this.color,
this.textAlign,
this.maxLines,
this.overflow,
this.textDirection,
this.fontStyle,
this.letterSpacing,
});
final String data;
final double baseFontSize;
final FontWeight fontWeight;
final double? height;
final Color? color;
final TextAlign? textAlign;
final int? maxLines;
final TextOverflow? overflow;
final TextDirection? textDirection;
final FontStyle? fontStyle;
final double? letterSpacing;
static const double _explicitLineHeightCompression = 0.9;
static const double _defaultArabicLineHeight = 1.8;
static const String _primaryArabicFontFamily = 'ScheherazadeNew';
static const List<String> _arabicFallbackFamilies = <String>[
'UthmanTahaNaskh',
'KFGQPCUthmanicHafs',
'Amiri',
'Noto Naskh Arabic',
'Noto Sans Arabic',
'Droid Arabic Naskh',
'sans-serif',
];
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<Box<AppSettings>>(
valueListenable: Hive.box<AppSettings>(HiveBoxes.settings)
.listenable(keys: ['default']),
builder: (_, box, __) {
final preferredSize = box.get('default')?.arabicFontSize ?? 24.0;
final adjustedSize = (baseFontSize + (preferredSize - 24.0))
.clamp(12.0, 56.0)
.toDouble();
final effectiveHeight = height == null
? _defaultArabicLineHeight
: (height! * _explicitLineHeightCompression)
.clamp(1.6, 2.35)
.toDouble();
return Text(
data,
textAlign: textAlign,
maxLines: maxLines,
overflow: overflow,
textDirection: textDirection,
strutStyle: StrutStyle(
fontFamily: _primaryArabicFontFamily,
fontSize: adjustedSize,
height: effectiveHeight,
leading: 0.08,
forceStrutHeight: true,
),
style: TextStyle(
fontFamily: _primaryArabicFontFamily,
fontFamilyFallback: _arabicFallbackFamilies,
fontSize: adjustedSize,
fontWeight: fontWeight,
height: effectiveHeight,
color: color,
fontStyle: fontStyle,
letterSpacing: letterSpacing,
),
);
},
);
}
}

View File

@@ -0,0 +1,686 @@
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:share_plus/share_plus.dart';
import '../../app/icons/app_icons.dart';
import '../../app/theme/app_colors.dart';
import '../../core/widgets/arabic_text.dart';
String buildAyatShareText(Map<String, dynamic> ayat) {
final arabic = (ayat['teksArab'] ?? '').toString().trim();
final translation = (ayat['teksIndonesia'] ?? '').toString().trim();
final surahName = (ayat['surahName'] ?? '').toString().trim();
final verseNumber = (ayat['nomorAyat'] ?? '').toString().trim();
final reference = surahName.isNotEmpty && verseNumber.isNotEmpty
? 'QS. $surahName: $verseNumber'
: 'Ayat Hari Ini';
final parts = <String>[
if (arabic.isNotEmpty) arabic,
if (translation.isNotEmpty) '"$translation"',
reference,
'Dibagikan dari Jam Shalat Diary',
];
return parts.join('\n\n');
}
Future<void> showAyatShareSheet(
BuildContext context,
Map<String, dynamic> ayat,
) async {
final shareText = buildAyatShareText(ayat);
final isDark = Theme.of(context).brightness == Brightness.dark;
await showModalBottomSheet<void>(
context: context,
useSafeArea: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
builder: (sheetContext) {
Future<void> handleShareImage() async {
Navigator.of(sheetContext).pop();
try {
final pngBytes = await _captureAyatShareCardPng(context, ayat);
final file = await _writeAyatShareImage(pngBytes);
await Share.shareXFiles(
[XFile(file.path)],
text: 'Ayat Hari Ini',
subject: 'Ayat Hari Ini',
);
} catch (_) {
if (!context.mounted) return;
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
const SnackBar(
content: Text('Gagal menyiapkan gambar ayat'),
duration: Duration(seconds: 2),
),
);
}
}
Future<void> handleShareText() async {
Navigator.of(sheetContext).pop();
await Share.share(
shareText,
subject: 'Ayat Hari Ini',
);
}
Future<void> handleCopyText() async {
await Clipboard.setData(ClipboardData(text: shareText));
if (!sheetContext.mounted) return;
Navigator.of(sheetContext).pop();
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
const SnackBar(
content: Text('Teks ayat disalin ke clipboard'),
duration: Duration(seconds: 2),
),
);
}
return Padding(
padding: const EdgeInsets.fromLTRB(16, 20, 16, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Bagikan Ayat',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 6),
Text(
'Pilih cara tercepat untuk membagikan ayat hari ini.',
style: TextStyle(
fontSize: 13,
height: 1.5,
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
),
const SizedBox(height: 18),
_AyatShareActionTile(
icon: const Icon(
LucideIcons.image,
size: 20,
color: AppColors.primary,
),
title: 'Bagikan Gambar',
subtitle: 'Kirim kartu ayat yang siap dibagikan',
badge: 'Utama',
onTap: handleShareImage,
),
const SizedBox(height: 10),
_AyatShareActionTile(
icon: const AppIcon(
glyph: AppIcons.share,
size: 20,
color: AppColors.primary,
),
title: 'Bagikan Teks',
subtitle: 'Kirim ayat dan terjemahan ke aplikasi lain',
onTap: handleShareText,
),
const SizedBox(height: 10),
_AyatShareActionTile(
icon: const Icon(
LucideIcons.copy,
size: 20,
color: AppColors.primary,
),
title: 'Salin Teks',
subtitle: 'Simpan ke clipboard untuk ditempel manual',
onTap: handleCopyText,
),
],
),
);
},
);
}
Future<Uint8List> _captureAyatShareCardPng(
BuildContext context,
Map<String, dynamic> ayat,
) async {
final overlay = Overlay.of(context, rootOverlay: true);
final boundaryKey = GlobalKey();
final theme = Theme.of(context);
final mediaQuery = MediaQuery.of(context);
final textDirection = Directionality.of(context);
late final OverlayEntry entry;
entry = OverlayEntry(
builder: (_) => IgnorePointer(
child: Material(
color: Colors.transparent,
child: Align(
alignment: Alignment.topCenter,
child: Opacity(
opacity: 0.01,
child: MediaQuery(
data: mediaQuery,
child: Theme(
data: theme,
child: Directionality(
textDirection: textDirection,
child: UnconstrainedBox(
constrainedAxis: Axis.horizontal,
child: RepaintBoundary(
key: boundaryKey,
child: _AyatShareCard(
ayat: ayat,
isDark: theme.brightness == Brightness.dark,
),
),
),
),
),
),
),
),
),
),
);
overlay.insert(entry);
try {
await Future<void>.delayed(const Duration(milliseconds: 20));
await WidgetsBinding.instance.endOfFrame;
await Future<void>.delayed(const Duration(milliseconds: 20));
final boundary = boundaryKey.currentContext?.findRenderObject()
as RenderRepaintBoundary?;
if (boundary == null) {
throw StateError('Ayat share card is not ready');
}
final image = await boundary.toImage(pixelRatio: 3);
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
if (byteData == null) {
throw StateError('Failed to encode ayat share card');
}
return byteData.buffer.asUint8List();
} finally {
entry.remove();
}
}
Future<File> _writeAyatShareImage(Uint8List pngBytes) async {
final directory = await Directory.systemTemp.createTemp('jamshalat_ayat_');
final file = File('${directory.path}/ayat_hari_ini.png');
await file.writeAsBytes(pngBytes, flush: true);
return file;
}
class _AyatShareCard extends StatelessWidget {
const _AyatShareCard({
required this.ayat,
required this.isDark,
});
final Map<String, dynamic> ayat;
final bool isDark;
@override
Widget build(BuildContext context) {
final arabic = (ayat['teksArab'] ?? '').toString().trim();
final translation = (ayat['teksIndonesia'] ?? '').toString().trim();
final surahName = (ayat['surahName'] ?? '').toString().trim();
final verseNumber = (ayat['nomorAyat'] ?? '').toString().trim();
final reference = surahName.isNotEmpty && verseNumber.isNotEmpty
? 'QS. $surahName: $verseNumber'
: 'Ayat Hari Ini';
final isLongArabic = arabic.length > 120;
final isVeryLongArabic = arabic.length > 180;
final isLongTranslation = translation.length > 140;
final isVeryLongTranslation = translation.length > 220;
final arabicFontSize = isVeryLongArabic
? 22.0
: isLongArabic
? 24.0
: 28.0;
final arabicHeight = isVeryLongArabic
? 1.55
: isLongArabic
? 1.62
: 1.75;
final translationFontSize = isVeryLongTranslation
? 13.0
: isLongTranslation
? 14.0
: 15.0;
final translationHeight = isVeryLongTranslation ? 1.5 : 1.6;
final verticalPadding = isVeryLongTranslation ? 22.0 : 24.0;
return SizedBox(
width: 360,
child: ClipRRect(
borderRadius: BorderRadius.circular(30),
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(30),
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: isDark
? const [
Color(0xFF102028),
Color(0xFF0F1217),
Color(0xFF16343A),
]
: const [
Color(0xFFF6FBFB),
Color(0xFFFFFFFF),
Color(0xFFEAF7F7),
],
),
boxShadow: [
BoxShadow(
color:
AppColors.primary.withValues(alpha: isDark ? 0.24 : 0.12),
blurRadius: 28,
offset: const Offset(0, 18),
),
],
),
child: Stack(
children: [
Positioned.fill(
child: IgnorePointer(
child: Padding(
padding: const EdgeInsets.all(14),
child: CustomPaint(
painter: _AyatFramePainter(isDark: isDark),
),
),
),
),
Positioned(
top: -38,
right: -34,
child: Container(
width: 116,
height: 116,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: AppColors.primary.withValues(alpha: 0.05),
),
),
),
Positioned(
bottom: -46,
left: -28,
child: Container(
width: 132,
height: 132,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: AppColors.navActiveGold.withValues(alpha: 0.05),
),
),
),
Padding(
padding: EdgeInsets.fromLTRB(
28,
28,
28,
verticalPadding,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: AppColors.primary.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(14),
),
child: const Icon(
LucideIcons.bookMarked,
size: 20,
color: AppColors.primary,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Ayat Hari Ini',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
letterSpacing: 1.3,
color: AppColors.primary,
),
),
const SizedBox(height: 3),
Text(
reference,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w800,
color: isDark
? Colors.white
: AppColors.textPrimaryLight,
),
),
],
),
),
],
),
if (arabic.isNotEmpty) ...[
const SizedBox(height: 28),
ArabicText(
arabic,
baseFontSize: arabicFontSize,
height: arabicHeight,
textAlign: TextAlign.right,
color: isDark
? AppColors.textPrimaryDark
: AppColors.textPrimaryLight,
),
],
if (translation.isNotEmpty) ...[
const SizedBox(height: 24),
Container(
width: double.infinity,
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: (isDark ? Colors.white : AppColors.primary)
.withValues(alpha: isDark ? 0.05 : 0.08),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: (isDark ? Colors.white : AppColors.primary)
.withValues(alpha: 0.08),
),
),
child: Text(
'"$translation"',
textAlign: TextAlign.left,
style: TextStyle(
fontSize: translationFontSize,
height: translationHeight,
fontStyle: FontStyle.italic,
color: isDark
? AppColors.textPrimaryDark
: AppColors.textPrimaryLight,
),
),
),
],
const SizedBox(height: 22),
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
decoration: BoxDecoration(
color:
AppColors.navActiveGold.withValues(alpha: 0.16),
borderRadius: BorderRadius.circular(999),
),
child: const Text(
'Jam Shalat Diary',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: AppColors.navActiveGoldDeep,
),
),
),
const Spacer(),
Flexible(
child: Text(
'Bagikan kebaikan',
textAlign: TextAlign.right,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
),
),
],
),
],
),
),
],
),
),
),
);
}
}
class _AyatShareActionTile extends StatelessWidget {
const _AyatShareActionTile({
required this.icon,
required this.title,
required this.subtitle,
required this.onTap,
this.badge,
});
final Widget icon;
final String title;
final String subtitle;
final VoidCallback onTap;
final String? badge;
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(18),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: isDark ? AppColors.surfaceDark : AppColors.surfaceLight,
borderRadius: BorderRadius.circular(18),
border: Border.all(
color: isDark
? AppColors.primary.withValues(alpha: 0.12)
: AppColors.cream,
),
),
child: Row(
children: [
Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: AppColors.primary.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(14),
),
child: Center(child: icon),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
),
),
if (badge != null) ...[
const SizedBox(height: 6),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color:
AppColors.navActiveGold.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(999),
),
child: Text(
badge!,
style: const TextStyle(
fontSize: 10,
fontWeight: FontWeight.w800,
color: AppColors.navActiveGoldDeep,
),
),
),
],
const SizedBox(height: 4),
Text(
subtitle,
style: TextStyle(
fontSize: 12,
height: 1.4,
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
),
],
),
),
Icon(
LucideIcons.chevronRight,
size: 18,
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
],
),
),
),
);
}
}
class _AyatFramePainter extends CustomPainter {
const _AyatFramePainter({required this.isDark});
final bool isDark;
@override
void paint(Canvas canvas, Size size) {
final outerRect = RRect.fromRectAndRadius(
Offset.zero & size,
const Radius.circular(22),
);
final outerPaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1.6
..shader = const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
AppColors.navActiveGoldPale,
AppColors.navActiveGold,
AppColors.navActiveGoldDeep,
],
).createShader(Offset.zero & size);
final outerGlow = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 5
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 6)
..color = AppColors.navActiveGold.withValues(alpha: isDark ? 0.08 : 0.05);
final innerBounds = Rect.fromLTWH(8, 8, size.width - 16, size.height - 16);
final innerFrame = RRect.fromRectAndRadius(
innerBounds,
const Radius.circular(18),
);
final innerPaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 0.8
..color = (isDark ? Colors.white : AppColors.primary)
.withValues(alpha: isDark ? 0.08 : 0.10);
canvas.drawRRect(outerRect, outerGlow);
canvas.drawRRect(outerRect, outerPaint);
canvas.drawRRect(innerFrame, innerPaint);
_drawMidMotif(canvas, size, top: true);
_drawMidMotif(canvas, size, top: false);
}
void _drawMidMotif(Canvas canvas, Size size, {required bool top}) {
final y = top ? 14.0 : size.height - 14.0;
final centerX = size.width / 2;
final linePaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 0.9
..strokeCap = StrokeCap.round
..color = AppColors.navActiveGold.withValues(alpha: isDark ? 0.26 : 0.22);
final diamondPaint = Paint()
..style = PaintingStyle.fill
..color = AppColors.primary.withValues(alpha: isDark ? 0.34 : 0.22);
final diamondStroke = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 0.9
..color = AppColors.navActiveGold.withValues(alpha: isDark ? 0.58 : 0.48);
canvas.drawLine(
Offset(centerX - 26, y),
Offset(centerX - 10, y),
linePaint,
);
canvas.drawLine(
Offset(centerX + 10, y),
Offset(centerX + 26, y),
linePaint,
);
final diamondPath = Path()
..moveTo(centerX, y - 5)
..lineTo(centerX + 5, y)
..lineTo(centerX, y + 5)
..lineTo(centerX - 5, y)
..close();
canvas.drawPath(diamondPath, diamondPaint);
canvas.drawPath(diamondPath, diamondStroke);
}
@override
bool shouldRepaint(covariant _AyatFramePainter oldDelegate) {
return oldDelegate.isDark != isDark;
}
}

View File

@@ -0,0 +1,214 @@
import 'package:flutter/material.dart';
import '../../app/icons/app_icons.dart';
import '../../app/theme/app_colors.dart';
import '../../data/services/muslim_api_service.dart';
import 'arabic_text.dart';
import 'ayat_share_sheet.dart';
class AyatTodayCard extends StatefulWidget {
const AyatTodayCard({
super.key,
required this.headerText,
required this.headerStyle,
});
final String headerText;
final TextStyle headerStyle;
@override
State<AyatTodayCard> createState() => _AyatTodayCardState();
}
class _AyatTodayCardState extends State<AyatTodayCard> {
Map<String, dynamic>? _dailyAyat;
Map<String, dynamic>? _activeAyat;
bool _isLoading = true;
bool _isRandomizing = false;
bool _showingRandomAyat = false;
@override
void initState() {
super.initState();
_loadDailyAyat();
}
Future<void> _loadDailyAyat() async {
final ayat = await MuslimApiService.instance.getDailyAyat();
if (!mounted) return;
setState(() {
_dailyAyat = ayat;
_activeAyat = ayat;
_showingRandomAyat = false;
_isLoading = false;
});
}
Future<void> _showRandomAyat() async {
if (_isRandomizing || _activeAyat == null) return;
setState(() => _isRandomizing = true);
final randomAyat = await MuslimApiService.instance.getRandomAyat(
excludeSurahNumber: _asInt(_activeAyat?['nomorSurah']),
excludeAyahNumber: _asInt(_activeAyat?['nomorAyat']),
);
if (!mounted) return;
setState(() {
_isRandomizing = false;
if (randomAyat != null) {
_activeAyat = randomAyat;
_showingRandomAyat = true;
}
});
}
void _restoreDailyAyat() {
if (_dailyAyat == null) return;
setState(() {
_activeAyat = _dailyAyat;
_showingRandomAyat = false;
});
}
int _asInt(dynamic value) {
if (value is int) return value;
if (value is num) return value.toInt();
if (value is String) return int.tryParse(value) ?? 0;
return 0;
}
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final backgroundColor = isDark
? AppColors.primary.withValues(alpha: 0.08)
: const Color(0xFFF5F9F0);
if (_isLoading) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(16),
),
child: const Center(child: CircularProgressIndicator()),
);
}
final data = _activeAyat;
if (data == null) return const SizedBox.shrink();
final secondaryColor =
isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight;
return Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(widget.headerText, style: widget.headerStyle)),
IconButton(
icon: AppIcon(
glyph: AppIcons.share,
size: 18,
color: secondaryColor,
),
tooltip: 'Bagikan ayat',
onPressed: () => showAyatShareSheet(context, data),
),
],
),
const SizedBox(height: 16),
Align(
alignment: Alignment.centerRight,
child: ArabicText(
data['teksArab'] ?? '',
baseFontSize: 24,
fontWeight: FontWeight.w400,
height: 1.8,
textAlign: TextAlign.right,
),
),
const SizedBox(height: 16),
Text(
'"${data['teksIndonesia'] ?? ''}"',
style: TextStyle(
fontSize: 14,
fontStyle: FontStyle.italic,
height: 1.5,
color: isDark ? Colors.white : Colors.black87,
),
),
const SizedBox(height: 12),
Text(
'QS. ${data['surahName']}: ${data['nomorAyat']}',
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.primary,
),
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
TextButton.icon(
onPressed: _isRandomizing ? null : _showRandomAyat,
style: TextButton.styleFrom(
foregroundColor: AppColors.primary,
backgroundColor: AppColors.primary.withValues(
alpha: isDark ? 0.16 : 0.12,
),
padding:
const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(999),
),
),
icon: _isRandomizing
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation(AppColors.primary),
),
)
: const AppIcon(
glyph: AppIcons.shuffle,
size: 16,
color: AppColors.primary,
),
label: Text(_showingRandomAyat ? 'Acak Lagi' : 'Ayat Lain'),
),
if (_showingRandomAyat)
TextButton(
onPressed: _restoreDailyAyat,
style: TextButton.styleFrom(
foregroundColor: secondaryColor,
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 10,
),
),
child: const Text('Kembali ke Hari Ini'),
),
],
),
],
),
);
}
}

View File

@@ -1,13 +1,18 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter/services.dart';
import 'package:hive_flutter/hive_flutter.dart';
import '../../app/icons/app_icons.dart';
import '../../app/theme/app_colors.dart';
import '../../core/providers/theme_provider.dart';
import '../../data/local/hive_boxes.dart';
import '../../data/local/models/app_settings.dart';
/// 5-tab bottom navigation bar per PRD §5.1.
/// Uses Material Symbols outlined (inactive) and filled (active).
class AppBottomNavBar extends StatelessWidget {
/// 5-tab bottom navigation bar with luxury active treatment.
class AppBottomNavBar extends ConsumerStatefulWidget {
const AppBottomNavBar({
super.key,
required this.currentIndex,
@@ -17,86 +22,207 @@ class AppBottomNavBar extends StatelessWidget {
final int currentIndex;
final ValueChanged<int> onTap;
@override
ConsumerState<AppBottomNavBar> createState() => _AppBottomNavBarState();
}
class _AppBottomNavBarState extends ConsumerState<AppBottomNavBar>
with TickerProviderStateMixin {
static const double _toggleRevealWidth = 88;
static const double _dragThreshold = 38;
static const double _inactiveIconSize = 27;
static const double _activeIconSize = 22;
static const double _navIconStrokeWidth = 1.9;
late final AnimationController _shineController;
late final AnimationController _revealController;
late final Animation<double> _revealAnimation;
bool _isThemeToggleOpen = false;
double _dragDeltaX = 0;
@override
void initState() {
super.initState();
_shineController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 5200),
)..repeat();
_revealController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 340),
);
_revealAnimation = CurvedAnimation(
parent: _revealController,
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeInCubic,
);
}
void _syncAnimation({required bool reducedMotion}) {
if (reducedMotion) {
if (_shineController.isAnimating) {
_shineController.stop(canceled: false);
}
return;
}
if (!_shineController.isAnimating) {
_shineController.repeat();
}
}
void _openThemeToggle({bool withHaptics = true}) {
if (_isThemeToggleOpen) return;
_isThemeToggleOpen = true;
if (withHaptics) {
HapticFeedback.mediumImpact();
}
_revealController.animateTo(1);
}
void _closeThemeToggle({bool withHaptics = true}) {
if (!_isThemeToggleOpen) return;
_isThemeToggleOpen = false;
if (withHaptics) {
HapticFeedback.lightImpact();
}
_revealController.animateBack(0);
}
void _handleNavTap(int index) {
_closeThemeToggle(withHaptics: false);
widget.onTap(index);
}
void _handleHorizontalDragStart(DragStartDetails _) {
_dragDeltaX = 0;
}
void _handleHorizontalDragUpdate(DragUpdateDetails details) {
_dragDeltaX += details.delta.dx;
}
void _handleHorizontalDragEnd(DragEndDetails details) {
final velocityX = details.primaryVelocity ?? 0;
final shouldOpen = velocityX < -220 || _dragDeltaX <= -_dragThreshold;
final shouldClose = velocityX > 220 || _dragDeltaX >= _dragThreshold;
if (shouldOpen && !_isThemeToggleOpen) {
_openThemeToggle();
} else if (shouldClose && _isThemeToggleOpen) {
_closeThemeToggle();
}
}
Future<void> _toggleThemeMode(BuildContext context) async {
final isDarkNow = Theme.of(context).brightness == Brightness.dark;
final nextMode = isDarkNow ? ThemeMode.light : ThemeMode.dark;
final box = Hive.box<AppSettings>(HiveBoxes.settings);
final settings = box.get('default') ?? AppSettings();
settings.themeModeIndex = nextMode == ThemeMode.dark ? 2 : 1;
if (settings.isInBox) {
await settings.save();
} else {
await box.put('default', settings);
}
ref.read(themeProvider.notifier).state = nextMode;
HapticFeedback.selectionClick();
if (mounted) {
_closeThemeToggle(withHaptics: false);
}
}
@override
void dispose() {
_shineController.dispose();
_revealController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final media = MediaQuery.maybeOf(context);
final reducedMotion = (media?.disableAnimations ?? false) ||
(media?.accessibleNavigation ?? false);
_syncAnimation(reducedMotion: reducedMotion);
return ValueListenableBuilder<Box<AppSettings>>(
valueListenable: Hive.box<AppSettings>(HiveBoxes.settings).listenable(),
builder: (context, box, _) {
final isSimpleMode = box.get('default')?.simpleMode ?? false;
final isDark = Theme.of(context).brightness == Brightness.dark;
final systemBottomInset =
MediaQueryData.fromView(View.of(context)).viewPadding.bottom;
final simpleItems = const [
BottomNavigationBarItem(
icon: Icon(LucideIcons.home),
activeIcon: Icon(LucideIcons.home),
label: 'Beranda',
),
BottomNavigationBarItem(
icon: Icon(LucideIcons.calendar),
activeIcon: Icon(LucideIcons.calendar),
label: 'Jadwal',
),
BottomNavigationBarItem(
icon: Icon(LucideIcons.bookOpen),
activeIcon: Icon(LucideIcons.bookOpen),
label: 'Tilawah',
),
BottomNavigationBarItem(
icon: Icon(LucideIcons.headphones),
activeIcon: Icon(LucideIcons.headphones),
label: 'Murattal',
),
BottomNavigationBarItem(
icon: Icon(LucideIcons.sparkles),
activeIcon: Icon(LucideIcons.sparkles),
label: 'Zikir',
),
final items = isSimpleMode
? const [
_NavDef(AppIcons.home, 'Beranda'),
_NavDef(AppIcons.calendar, 'Jadwal'),
_NavDef(AppIcons.quran, "Al-Qur'an"),
_NavDef(AppIcons.dzikir, 'Dzikir'),
_NavDef(AppIcons.lainnya, 'Lainnya'),
]
: const [
_NavDef(AppIcons.home, 'Beranda'),
_NavDef(AppIcons.calendar, 'Jadwal'),
_NavDef(AppIcons.ibadah, 'Ibadah'),
_NavDef(AppIcons.laporan, 'Laporan'),
_NavDef(AppIcons.lainnya, 'Lainnya'),
];
final fullItems = const [
BottomNavigationBarItem(
icon: Icon(LucideIcons.home),
activeIcon: Icon(LucideIcons.home),
label: 'Beranda',
),
BottomNavigationBarItem(
icon: Icon(LucideIcons.calendar),
activeIcon: Icon(LucideIcons.calendar),
label: 'Jadwal',
),
BottomNavigationBarItem(
icon: Icon(LucideIcons.listChecks),
activeIcon: Icon(LucideIcons.listChecks),
label: 'Ibadah',
),
BottomNavigationBarItem(
icon: Icon(LucideIcons.barChart3),
activeIcon: Icon(LucideIcons.barChart3),
label: 'Laporan',
),
BottomNavigationBarItem(
icon: Icon(LucideIcons.wand2),
activeIcon: Icon(LucideIcons.wand2),
label: 'Alat',
),
];
return Container(
decoration: BoxDecoration(
color: Theme.of(context).bottomNavigationBarTheme.backgroundColor,
border: Border(
top: BorderSide(
color: Theme.of(context).dividerColor,
width: 0.5,
),
),
),
child: SafeArea(
return ColoredBox(
color: Theme.of(context).bottomNavigationBarTheme.backgroundColor ??
Colors.transparent,
child: Padding(
padding: const EdgeInsets.only(bottom: 8),
child: BottomNavigationBar(
currentIndex: currentIndex,
onTap: onTap,
items: isSimpleMode ? simpleItems : fullItems,
padding: EdgeInsets.fromLTRB(10, 6, 10, 6 + systemBottomInset),
child: GestureDetector(
behavior: HitTestBehavior.deferToChild,
onHorizontalDragStart: _handleHorizontalDragStart,
onHorizontalDragUpdate: _handleHorizontalDragUpdate,
onHorizontalDragEnd: _handleHorizontalDragEnd,
child: SizedBox(
height: 56,
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned.fill(
child: AnimatedBuilder(
animation: _revealController,
builder: (context, child) {
final reveal = _revealAnimation.value;
return IgnorePointer(
ignoring: reveal < 0.55,
child: _ThemeUnderlayBoard(
isDark: isDark,
reveal: reveal,
onTap: () => _toggleThemeMode(context),
),
);
},
),
),
AnimatedBuilder(
animation: _revealController,
builder: (context, child) {
final reveal = _revealAnimation.value;
return Transform.translate(
offset: Offset(-_toggleRevealWidth * reveal, 0),
child: _MainNavBoard(
items: items,
currentIndex: widget.currentIndex,
isDark: isDark,
reducedMotion: reducedMotion,
animation: _shineController,
onTap: _handleNavTap,
),
);
},
),
],
),
),
),
),
@@ -105,3 +231,477 @@ class AppBottomNavBar extends StatelessWidget {
);
}
}
class _ThemeUnderlayBoard extends StatelessWidget {
const _ThemeUnderlayBoard({
required this.isDark,
required this.reveal,
required this.onTap,
});
final bool isDark;
final double reveal;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final t = reveal.clamp(0.0, 1.0).toDouble();
return Container(
height: 54,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(28),
color: isDark ? const Color(0xFF090D14) : const Color(0xFFE6E6EA),
border: Border.all(
color: isDark ? const Color(0x1FFFFFFF) : const Color(0x140F172A),
width: 0.8,
),
),
child: Align(
alignment: Alignment.centerRight,
child: SizedBox(
width: _AppBottomNavBarState._toggleRevealWidth,
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(22),
child: Center(
child: Opacity(
opacity: t,
child: Transform.translate(
offset: Offset((1 - t) * 10, 0),
child: Transform.scale(
scale: 0.94 + (t * 0.06),
child: AppIcon(
glyph: isDark ? AppIcons.themeSun : AppIcons.themeMoon,
size: 24,
color: isDark
? AppColors.navActiveGoldPale
: AppColors.textPrimaryLight,
),
),
),
),
),
),
),
),
),
);
}
}
class _NavDef {
const _NavDef(this.icon, this.label);
final AppIconGlyph icon;
final String label;
}
class _MainNavBoard extends StatelessWidget {
const _MainNavBoard({
required this.items,
required this.currentIndex,
required this.isDark,
required this.reducedMotion,
required this.animation,
required this.onTap,
});
final List<_NavDef> items;
final int currentIndex;
final bool isDark;
final bool reducedMotion;
final Animation<double> animation;
final ValueChanged<int> onTap;
@override
Widget build(BuildContext context) {
final background =
Theme.of(context).bottomNavigationBarTheme.backgroundColor ??
(isDark ? AppColors.surfaceDark : AppColors.surfaceLight);
return DecoratedBox(
decoration: BoxDecoration(
color: background,
borderRadius: BorderRadius.circular(28),
),
child: Row(
children: List.generate(items.length, (index) {
final item = items[index];
final isSelected = index == currentIndex;
return Expanded(
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: () => onTap(index),
customBorder: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(22),
),
child: SizedBox(
height: 56,
child: Center(
child: isSelected
? _AnimatedLuxuryActiveIcon(
animation: animation,
icon: item.icon,
isDark: isDark,
reducedMotion: reducedMotion,
iconSize: _AppBottomNavBarState._activeIconSize,
)
: _InactiveNavIcon(
glyph: item.icon,
isDark: isDark,
iconSize: _AppBottomNavBarState._inactiveIconSize,
),
),
),
),
),
);
}),
),
);
}
}
class _InactiveNavIcon extends StatelessWidget {
const _InactiveNavIcon({
required this.glyph,
required this.isDark,
required this.iconSize,
});
final AppIconGlyph glyph;
final bool isDark;
final double iconSize;
@override
Widget build(BuildContext context) {
return SizedBox(
width: 48,
height: 48,
child: Center(
child: AppIcon(
glyph: glyph,
size: iconSize,
strokeWidth: _AppBottomNavBarState._navIconStrokeWidth,
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
),
);
}
}
class _AnimatedLuxuryActiveIcon extends StatelessWidget {
const _AnimatedLuxuryActiveIcon({
required this.animation,
required this.icon,
required this.isDark,
required this.reducedMotion,
required this.iconSize,
});
final Animation<double> animation;
final AppIconGlyph icon;
final bool isDark;
final bool reducedMotion;
final double iconSize;
@override
Widget build(BuildContext context) {
return RepaintBoundary(
child: AnimatedBuilder(
animation: animation,
builder: (_, __) {
return _LuxuryActiveIcon(
icon: icon,
isDark: isDark,
reducedMotion: reducedMotion,
progress: reducedMotion ? 0.17 : animation.value,
iconSize: iconSize,
);
},
),
);
}
}
class _LuxuryActiveIcon extends StatelessWidget {
const _LuxuryActiveIcon({
required this.icon,
required this.isDark,
required this.reducedMotion,
required this.progress,
required this.iconSize,
});
final AppIconGlyph icon;
final bool isDark;
final bool reducedMotion;
final double progress;
final double iconSize;
@override
Widget build(BuildContext context) {
final baseShadow = isDark
? <BoxShadow>[
const BoxShadow(
color: Color(0xB3000000),
blurRadius: 9,
spreadRadius: 0.2,
offset: Offset(0, 4),
),
const BoxShadow(
color: AppColors.navGlowDark,
blurRadius: 8,
spreadRadius: -0.8,
offset: Offset(0, 1.5),
),
]
: <BoxShadow>[
const BoxShadow(
color: AppColors.navEmbossHighlight,
blurRadius: 3.2,
offset: Offset(-1.1, -1.1),
),
const BoxShadow(
color: AppColors.navEmbossShadow,
blurRadius: 7,
offset: Offset(2.3, 3.1),
),
];
return SizedBox(
width: 48,
height: 48,
child: Container(
decoration: BoxDecoration(
color: isDark ? const Color(0xFF11161F) : const Color(0xFFF2F3F5),
borderRadius: BorderRadius.circular(15),
border: Border.all(
color: isDark ? const Color(0x383D4C61) : const Color(0x2610172A),
width: 0.85,
),
boxShadow: baseShadow,
),
child: Padding(
padding: const EdgeInsets.all(2.0),
child: DecoratedBox(
decoration: BoxDecoration(
color: isDark
? const Color(0xFF1A202A)
: AppColors.navActiveSurfaceLight,
borderRadius: BorderRadius.circular(12.5),
border: Border.all(
color:
isDark ? const Color(0x2EFFFFFF) : const Color(0x1F0F172A),
width: 0.8,
),
),
child: CustomPaint(
painter: _LuxuryRingPainter(
progress: progress,
reducedMotion: reducedMotion,
isDark: isDark,
),
child: Center(
child: AppIcon(
glyph: icon,
size: iconSize,
strokeWidth: _AppBottomNavBarState._navIconStrokeWidth,
color: isDark
? AppColors.textPrimaryDark
: AppColors.textPrimaryLight,
),
),
),
),
),
),
);
}
}
class _LuxuryRingPainter extends CustomPainter {
const _LuxuryRingPainter({
required this.progress,
required this.reducedMotion,
required this.isDark,
});
final double progress;
final bool reducedMotion;
final bool isDark;
@override
void paint(Canvas canvas, Size size) {
final rect = Offset.zero & size;
final outerRect = rect.deflate(1.05);
final ringRect = rect.deflate(2.5);
final innerRect = rect.deflate(4.75);
final outerRRect =
RRect.fromRectAndRadius(outerRect, const Radius.circular(12.4));
final ringRRect =
RRect.fromRectAndRadius(ringRect, const Radius.circular(10.8));
final innerRRect =
RRect.fromRectAndRadius(innerRect, const Radius.circular(9.1));
final rotation = reducedMotion ? math.pi * 0.63 : progress * math.pi * 2;
void drawEmboss(
RRect target, {
required Offset shadowOffset,
required Offset highlightOffset,
required Color shadowColor,
required Color highlightColor,
required double shadowBlur,
required double highlightBlur,
required double strokeWidth,
}) {
final shadowPaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth
..color = shadowColor
..maskFilter = MaskFilter.blur(BlurStyle.normal, shadowBlur);
final highlightPaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth
..color = highlightColor
..maskFilter = MaskFilter.blur(BlurStyle.normal, highlightBlur);
canvas.save();
canvas.translate(shadowOffset.dx, shadowOffset.dy);
canvas.drawRRect(target, shadowPaint);
canvas.restore();
canvas.save();
canvas.translate(highlightOffset.dx, highlightOffset.dy);
canvas.drawRRect(target, highlightPaint);
canvas.restore();
}
drawEmboss(
outerRRect,
shadowOffset: const Offset(0.7, 1.0),
highlightOffset: const Offset(-0.6, -0.7),
shadowColor: isDark
? const Color(0xD6000000)
: AppColors.navEmbossShadow.withValues(alpha: 0.72),
highlightColor: isDark
? Colors.white.withValues(alpha: 0.22)
: Colors.white.withValues(alpha: 0.92),
shadowBlur: isDark ? 2.5 : 1.8,
highlightBlur: isDark ? 1.6 : 1.1,
strokeWidth: 1.05,
);
drawEmboss(
innerRRect,
shadowOffset: const Offset(-0.45, -0.35),
highlightOffset: const Offset(0.45, 0.55),
shadowColor: isDark
? Colors.black.withValues(alpha: 0.58)
: AppColors.navEmbossShadow.withValues(alpha: 0.58),
highlightColor: isDark
? Colors.white.withValues(alpha: 0.14)
: Colors.white.withValues(alpha: 0.78),
shadowBlur: isDark ? 1.5 : 1.2,
highlightBlur: isDark ? 1.1 : 0.9,
strokeWidth: 0.88,
);
final metallicRing = SweepGradient(
startAngle: rotation,
endAngle: rotation + math.pi * 2,
colors: const [
AppColors.navActiveGoldDeep,
AppColors.navActiveGold,
AppColors.navActiveGoldBright,
AppColors.navActiveGoldPale,
AppColors.navActiveGoldBright,
AppColors.navActiveGoldDeep,
],
stops: const [0.0, 0.16, 0.34, 0.5, 0.68, 1.0],
);
final metallicPaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1.9
..shader = metallicRing.createShader(ringRect)
..isAntiAlias = true;
final chromaStrength = isDark ? 0.92 : 0.74;
final chromaSweep = SweepGradient(
startAngle: (rotation * 1.3) + 0.42,
endAngle: (rotation * 1.3) + 0.42 + math.pi * 2,
colors: [
Colors.transparent,
Colors.transparent,
AppColors.navActiveGold.withValues(alpha: 0.52 * chromaStrength),
Colors.white.withValues(alpha: 0.94 * chromaStrength),
AppColors.navActiveGoldPale.withValues(alpha: 0.68 * chromaStrength),
Colors.transparent,
Colors.transparent,
AppColors.navActiveGold.withValues(alpha: 0.44 * chromaStrength),
Colors.white.withValues(alpha: 0.88 * chromaStrength),
AppColors.navActiveGoldPale.withValues(alpha: 0.64 * chromaStrength),
Colors.transparent,
Colors.transparent,
],
stops: const [
0.0,
0.09,
0.112,
0.126,
0.14,
0.175,
0.45,
0.468,
0.484,
0.5,
0.528,
1.0,
],
);
final chromaPaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1.15
..shader = chromaSweep.createShader(ringRect)
..blendMode = BlendMode.screen;
canvas.drawRRect(ringRRect, metallicPaint);
canvas.drawRRect(ringRRect, chromaPaint);
final ambientGold = Paint()
..style = PaintingStyle.stroke
..strokeWidth = isDark ? 1.0 : 0.85
..color = isDark
? AppColors.navActiveGold.withValues(alpha: 0.18)
: AppColors.navActiveGoldDeep.withValues(alpha: 0.16)
..maskFilter = MaskFilter.blur(
BlurStyle.normal,
isDark ? 2.6 : 1.2,
);
canvas.drawRRect(ringRRect, ambientGold);
final innerEdge = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 0.8
..color = isDark
? Colors.white.withValues(alpha: 0.12)
: const Color(0x330F172A);
canvas.drawRRect(innerRRect, innerEdge);
}
@override
bool shouldRepaint(covariant _LuxuryRingPainter oldDelegate) {
return oldDelegate.progress != progress ||
oldDelegate.reducedMotion != reducedMotion ||
oldDelegate.isDark != isDark;
}
}

View File

@@ -0,0 +1,166 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:hive_flutter/hive_flutter.dart';
import '../../app/icons/app_icons.dart';
import '../../app/theme/app_colors.dart';
import '../../data/local/hive_boxes.dart';
import '../../data/local/models/app_settings.dart';
import '../../data/services/notification_service.dart';
import '../../data/services/notification_inbox_service.dart';
import '../../features/dashboard/data/prayer_times_provider.dart';
class NotificationBellButton extends StatelessWidget {
const NotificationBellButton({
super.key,
this.iconColor,
this.iconSize = 22,
this.onPressed,
this.showBadge = true,
});
final Color? iconColor;
final double iconSize;
final VoidCallback? onPressed;
final bool showBadge;
@override
Widget build(BuildContext context) {
final inbox = NotificationInboxService.instance;
return ValueListenableBuilder(
valueListenable: inbox.listenable(),
builder: (context, _, __) {
final unread = showBadge ? inbox.unreadCount() : 0;
return IconButton(
onPressed: onPressed ??
() {
context.push('/notifications');
},
onLongPress: () => _showQuickActions(context),
icon: Stack(
clipBehavior: Clip.none,
children: [
AppIcon(
glyph: AppIcons.notification,
color: iconColor,
size: iconSize,
),
if (unread > 0)
Positioned(
right: -6,
top: -4,
child: Container(
constraints:
const BoxConstraints(minWidth: 16, minHeight: 16),
padding: const EdgeInsets.symmetric(horizontal: 4),
decoration: BoxDecoration(
color: AppColors.errorLight,
borderRadius: BorderRadius.circular(99),
border: Border.all(
color: Theme.of(context).scaffoldBackgroundColor,
width: 1.4,
),
),
child: Center(
child: Text(
unread > 99 ? '99+' : '$unread',
style: const TextStyle(
color: Colors.white,
fontSize: 9,
height: 1,
fontWeight: FontWeight.w700,
),
),
),
),
),
],
),
);
},
);
}
Future<void> _showQuickActions(BuildContext context) async {
final settingsBox = Hive.box<AppSettings>(HiveBoxes.settings);
final settings = settingsBox.get('default') ?? AppSettings();
final alarmsOn = settings.adhanEnabled.values.any((v) => v);
final isDark = Theme.of(context).brightness == Brightness.dark;
await showModalBottomSheet<void>(
context: context,
backgroundColor:
isDark ? AppColors.surfaceDarkElevated : AppColors.surfaceLight,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(22)),
),
builder: (sheetContext) {
return SafeArea(
top: false,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 8),
Container(
width: 44,
height: 4,
decoration: BoxDecoration(
color: isDark
? AppColors.textSecondaryDark.withValues(alpha: 0.4)
: AppColors.textSecondaryLight.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(999),
),
),
const SizedBox(height: 10),
ListTile(
leading: Icon(
alarmsOn
? Icons.notifications_off_outlined
: Icons.notifications_active_outlined,
),
title: Text(alarmsOn
? 'Nonaktifkan Alarm Sholat'
: 'Aktifkan Alarm Sholat'),
onTap: () async {
final container =
ProviderScope.containerOf(context, listen: false);
settings.adhanEnabled.updateAll((key, _) => !alarmsOn);
await settings.save();
if (alarmsOn) {
await NotificationService.instance.cancelAllPending();
}
container.invalidate(prayerTimesProvider);
unawaited(container.read(prayerTimesProvider.future));
if (sheetContext.mounted) Navigator.pop(sheetContext);
},
),
ListTile(
leading: const Icon(Icons.sync_rounded),
title: const Text('Sinkronkan Sekarang'),
onTap: () {
final container =
ProviderScope.containerOf(context, listen: false);
container.invalidate(prayerTimesProvider);
unawaited(container.read(prayerTimesProvider.future));
if (sheetContext.mounted) Navigator.pop(sheetContext);
},
),
ListTile(
leading: const Icon(Icons.settings_outlined),
title: const Text('Buka Pengaturan'),
onTap: () {
if (sheetContext.mounted) Navigator.pop(sheetContext);
context.push('/settings');
},
),
const SizedBox(height: 8),
],
),
);
},
);
}
}

View File

@@ -1,8 +1,9 @@
import 'package:flutter/material.dart';
import '../../app/icons/app_icons.dart';
import '../../app/theme/app_colors.dart';
class ToolCard extends StatelessWidget {
final IconData icon;
final AppIconGlyph icon;
final String title;
final Color color;
final bool isDark;
@@ -28,9 +29,7 @@ class ToolCard extends StatelessWidget {
color: isDark ? AppColors.surfaceDark : AppColors.surfaceLight,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: isDark
? color.withValues(alpha: 0.15)
: AppColors.cream,
color: isDark ? color.withValues(alpha: 0.15) : AppColors.cream,
),
boxShadow: [
BoxShadow(
@@ -51,7 +50,14 @@ class ToolCard extends StatelessWidget {
color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
),
child: Icon(icon, color: color, size: 24),
child: Padding(
padding: const EdgeInsets.all(8),
child: AppIcon(
glyph: icon,
color: color,
size: 24,
),
),
),
Text(
title,

View File

@@ -21,6 +21,8 @@ class HiveBoxes {
static const String dzikirCounters = 'dzikir_counters';
static const String bookmarks = 'bookmarks';
static const String cachedPrayerTimes = 'cached_prayer_times';
static const String notificationInbox = 'notification_inbox';
static const String notificationRuntime = 'notification_runtime';
}
/// Initialize Hive and open all boxes.
@@ -56,6 +58,8 @@ Future<void> initHive() async {
await Hive.openBox<DzikirCounter>(HiveBoxes.dzikirCounters);
await Hive.openBox<QuranBookmark>(HiveBoxes.bookmarks);
await Hive.openBox<CachedPrayerTimes>(HiveBoxes.cachedPrayerTimes);
await Hive.openBox(HiveBoxes.notificationInbox);
await Hive.openBox(HiveBoxes.notificationRuntime);
// MIGRATION: Delete legacy logs that crash due to type casts (Map<String, bool> vs Map<String, ShalatLog>)
final keysToDelete = [];
@@ -89,26 +93,53 @@ Future<void> seedDefaults() async {
if (checklistBox.isEmpty) {
final defaults = [
ChecklistItem(
id: 'fajr', title: 'Sholat Fajr', category: 'sholat_fardhu', sortOrder: 0),
id: 'fajr',
title: 'Sholat Fajr',
category: 'sholat_fardhu',
sortOrder: 0),
ChecklistItem(
id: 'dhuhr', title: 'Sholat Dhuhr', category: 'sholat_fardhu', sortOrder: 1),
id: 'dhuhr',
title: 'Sholat Dhuhr',
category: 'sholat_fardhu',
sortOrder: 1),
ChecklistItem(
id: 'asr', title: 'Sholat Asr', category: 'sholat_fardhu', sortOrder: 2),
id: 'asr',
title: 'Sholat Asr',
category: 'sholat_fardhu',
sortOrder: 2),
ChecklistItem(
id: 'maghrib', title: 'Sholat Maghrib', category: 'sholat_fardhu', sortOrder: 3),
id: 'maghrib',
title: 'Sholat Maghrib',
category: 'sholat_fardhu',
sortOrder: 3),
ChecklistItem(
id: 'isha', title: 'Sholat Isha', category: 'sholat_fardhu', sortOrder: 4),
id: 'isha',
title: 'Sholat Isha',
category: 'sholat_fardhu',
sortOrder: 4),
ChecklistItem(
id: 'tilawah', title: 'Tilawah Quran', category: 'tilawah',
subtitle: '1 Juz', sortOrder: 5),
id: 'tilawah',
title: 'Tilawah Quran',
category: 'tilawah',
subtitle: '1 Juz',
sortOrder: 5),
ChecklistItem(
id: 'dzikir_pagi', title: 'Dzikir Pagi', category: 'dzikir',
subtitle: '1 session', sortOrder: 6),
id: 'dzikir_pagi',
title: 'Dzikir Pagi',
category: 'dzikir',
subtitle: '1 session',
sortOrder: 6),
ChecklistItem(
id: 'dzikir_petang', title: 'Dzikir Petang', category: 'dzikir',
subtitle: '1 session', sortOrder: 7),
id: 'dzikir_petang',
title: 'Dzikir Petang',
category: 'dzikir',
subtitle: '1 session',
sortOrder: 7),
ChecklistItem(
id: 'rawatib', title: 'Sholat Sunnah Rawatib', category: 'sunnah', sortOrder: 8),
id: 'rawatib',
title: 'Sholat Sunnah Rawatib',
category: 'sunnah',
sortOrder: 8),
ChecklistItem(
id: 'shodaqoh', title: 'Shodaqoh', category: 'charity', sortOrder: 9),
];

View File

@@ -65,6 +65,45 @@ class AppSettings extends HiveObject {
@HiveField(19)
bool simpleMode; // false = Mode Lengkap, true = Mode Simpel
@HiveField(20)
String dzikirDisplayMode; // 'list' | 'focus'
@HiveField(21)
String dzikirCounterButtonPosition; // 'bottomPill' | 'fabCircle'
@HiveField(22)
bool dzikirAutoAdvance;
@HiveField(23)
bool dzikirHapticOnCount;
@HiveField(24)
bool alertsEnabled;
@HiveField(25)
bool inboxEnabled;
@HiveField(26)
bool streakRiskEnabled;
@HiveField(27)
bool dailyChecklistReminderEnabled;
@HiveField(28)
bool weeklySummaryEnabled;
@HiveField(29)
String quietHoursStart; // HH:mm
@HiveField(30)
String quietHoursEnd; // HH:mm
@HiveField(31)
int maxNonPrayerPushPerDay;
@HiveField(32)
bool mirrorAdzanToInbox;
AppSettings({
this.userName = 'User',
this.userEmail = '',
@@ -86,6 +125,19 @@ class AppSettings extends HiveObject {
this.showLatin = true,
this.showTerjemahan = true,
this.simpleMode = false,
this.dzikirDisplayMode = 'list',
this.dzikirCounterButtonPosition = 'bottomPill',
this.dzikirAutoAdvance = true,
this.dzikirHapticOnCount = true,
this.alertsEnabled = true,
this.inboxEnabled = true,
this.streakRiskEnabled = true,
this.dailyChecklistReminderEnabled = false,
this.weeklySummaryEnabled = true,
this.quietHoursStart = '22:00',
this.quietHoursEnd = '05:00',
this.maxNonPrayerPushPerDay = 2,
this.mirrorAdzanToInbox = false,
}) : adhanEnabled = adhanEnabled ??
{
'fajr': true,

View File

@@ -20,30 +20,65 @@ class AppSettingsAdapter extends TypeAdapter<AppSettings> {
userName: fields.containsKey(0) ? fields[0] as String? ?? '' : '',
userEmail: fields.containsKey(1) ? fields[1] as String? ?? '' : '',
themeModeIndex: fields.containsKey(2) ? fields[2] as int? ?? 0 : 0,
arabicFontSize: fields.containsKey(3) ? fields[3] as double? ?? 26.0 : 26.0,
arabicFontSize:
fields.containsKey(3) ? fields[3] as double? ?? 26.0 : 26.0,
uiLanguage: fields.containsKey(4) ? fields[4] as String? ?? 'id' : 'id',
adhanEnabled: fields.containsKey(5) ? (fields[5] as Map?)?.cast<String, bool>() : null,
iqamahOffset: fields.containsKey(6) ? (fields[6] as Map?)?.cast<String, int>() : null,
checklistReminderTime: fields.containsKey(7) ? fields[7] as String? : null,
adhanEnabled: fields.containsKey(5)
? (fields[5] as Map?)?.cast<String, bool>()
: null,
iqamahOffset: fields.containsKey(6)
? (fields[6] as Map?)?.cast<String, int>()
: null,
checklistReminderTime:
fields.containsKey(7) ? fields[7] as String? : null,
lastLat: fields.containsKey(8) ? fields[8] as double? : null,
lastLng: fields.containsKey(9) ? fields[9] as double? : null,
lastCityName: fields.containsKey(10) ? fields[10] as String? : null,
rawatibLevel: fields.containsKey(11) ? fields[11] as int? ?? 1 : 1,
tilawahTargetValue: fields.containsKey(12) ? fields[12] as int? ?? 1 : 1,
tilawahTargetUnit: fields.containsKey(13) ? fields[13] as String? ?? 'Juz' : 'Juz',
tilawahAutoSync: fields.containsKey(14) ? fields[14] as bool? ?? false : false,
tilawahTargetUnit:
fields.containsKey(13) ? fields[13] as String? ?? 'Juz' : 'Juz',
tilawahAutoSync:
fields.containsKey(14) ? fields[14] as bool? ?? false : false,
trackDzikir: fields.containsKey(15) ? fields[15] as bool? ?? true : true,
trackPuasa: fields.containsKey(16) ? fields[16] as bool? ?? false : false,
showLatin: fields.containsKey(17) ? fields[17] as bool? ?? true : true,
showTerjemahan: fields.containsKey(18) ? fields[18] as bool? ?? true : true,
showTerjemahan:
fields.containsKey(18) ? fields[18] as bool? ?? true : true,
simpleMode: fields.containsKey(19) ? fields[19] as bool? ?? false : false,
dzikirDisplayMode:
fields.containsKey(20) ? fields[20] as String? ?? 'list' : 'list',
dzikirCounterButtonPosition: fields.containsKey(21)
? fields[21] as String? ?? 'bottomPill'
: 'bottomPill',
dzikirAutoAdvance:
fields.containsKey(22) ? fields[22] as bool? ?? true : true,
dzikirHapticOnCount:
fields.containsKey(23) ? fields[23] as bool? ?? true : true,
alertsEnabled:
fields.containsKey(24) ? fields[24] as bool? ?? true : true,
inboxEnabled: fields.containsKey(25) ? fields[25] as bool? ?? true : true,
streakRiskEnabled:
fields.containsKey(26) ? fields[26] as bool? ?? true : true,
dailyChecklistReminderEnabled:
fields.containsKey(27) ? fields[27] as bool? ?? false : false,
weeklySummaryEnabled:
fields.containsKey(28) ? fields[28] as bool? ?? true : true,
quietHoursStart:
fields.containsKey(29) ? fields[29] as String? ?? '22:00' : '22:00',
quietHoursEnd:
fields.containsKey(30) ? fields[30] as String? ?? '05:00' : '05:00',
maxNonPrayerPushPerDay:
fields.containsKey(31) ? fields[31] as int? ?? 2 : 2,
mirrorAdzanToInbox:
fields.containsKey(32) ? fields[32] as bool? ?? false : false,
);
}
@override
void write(BinaryWriter writer, AppSettings obj) {
writer
..writeByte(20)
..writeByte(33)
..writeByte(0)
..write(obj.userName)
..writeByte(1)
@@ -83,7 +118,33 @@ class AppSettingsAdapter extends TypeAdapter<AppSettings> {
..writeByte(18)
..write(obj.showTerjemahan)
..writeByte(19)
..write(obj.simpleMode);
..write(obj.simpleMode)
..writeByte(20)
..write(obj.dzikirDisplayMode)
..writeByte(21)
..write(obj.dzikirCounterButtonPosition)
..writeByte(22)
..write(obj.dzikirAutoAdvance)
..writeByte(23)
..write(obj.dzikirHapticOnCount)
..writeByte(24)
..write(obj.alertsEnabled)
..writeByte(25)
..write(obj.inboxEnabled)
..writeByte(26)
..write(obj.streakRiskEnabled)
..writeByte(27)
..write(obj.dailyChecklistReminderEnabled)
..writeByte(28)
..write(obj.weeklySummaryEnabled)
..writeByte(29)
..write(obj.quietHoursStart)
..writeByte(30)
..write(obj.quietHoursEnd)
..writeByte(31)
..write(obj.maxNonPrayerPushPerDay)
..writeByte(32)
..write(obj.mirrorAdzanToInbox);
}
@override

View File

@@ -0,0 +1,657 @@
import 'dart:convert';
import 'dart:math';
import 'package:http/http.dart' as http;
class MuslimApiException implements Exception {
final String message;
const MuslimApiException(this.message);
@override
String toString() => 'MuslimApiException: $message';
}
/// Service for muslim.backoffice.biz.id API.
///
/// Exposes Quran, dzikir, doa, hadits, and enrichment data while preserving
/// the data contract currently expected by Quran and dashboard UI widgets.
class MuslimApiService {
static const String _baseUrl = 'https://muslim.backoffice.biz.id';
static final MuslimApiService instance = MuslimApiService._();
MuslimApiService._();
static const Map<String, String> qariNames = {
'01': 'Abdullah Al-Juhany',
'02': 'Abdul Muhsin Al-Qasim',
'03': 'Abdurrahman As-Sudais',
'04': 'Ibrahim Al-Dossari',
'05': 'Misyari Rasyid Al-Afasi',
'06': 'Yasser Al-Dosari',
};
List<Map<String, dynamic>>? _surahListCache;
final Map<int, Map<String, dynamic>> _surahCache = {};
List<Map<String, dynamic>>? _allAyahCache;
List<Map<String, dynamic>>? _tafsirCache;
List<Map<String, dynamic>>? _asbabCache;
List<Map<String, dynamic>>? _juzCache;
List<Map<String, dynamic>>? _themeCache;
List<Map<String, dynamic>>? _asmaCache;
List<Map<String, dynamic>>? _doaCache;
List<Map<String, dynamic>>? _haditsCache;
final Map<String, List<Map<String, dynamic>>> _dzikirByTypeCache = {};
final Map<String, List<Map<String, dynamic>>> _wordByWordCache = {};
final Map<int, List<Map<String, dynamic>>> _pageAyahCache = {};
Future<dynamic> _getData(String path) async {
try {
final response = await http.get(Uri.parse('$_baseUrl$path'));
if (response.statusCode != 200) {
return null;
}
final decoded = json.decode(response.body);
if (decoded is Map<String, dynamic>) {
return decoded['data'];
}
return null;
} catch (_) {
return null;
}
}
Future<dynamic> _getDataOrThrow(String path) async {
final response = await http.get(Uri.parse('$_baseUrl$path'));
if (response.statusCode != 200) {
throw MuslimApiException(
'Request failed ($path): HTTP ${response.statusCode}',
);
}
final decoded = json.decode(response.body);
if (decoded is! Map<String, dynamic>) {
throw const MuslimApiException('Invalid API payload shape');
}
final status = _asInt(decoded['status']);
if (status != 200) {
throw MuslimApiException('API returned non-200 status: $status');
}
if (!decoded.containsKey('data')) {
throw const MuslimApiException('API payload missing data key');
}
return decoded['data'];
}
int _asInt(dynamic value, {int fallback = 0}) {
if (value is int) return value;
if (value is num) return value.toInt();
if (value is String) return int.tryParse(value) ?? fallback;
return fallback;
}
String _asString(dynamic value, {String fallback = ''}) {
if (value == null) return fallback;
return value.toString();
}
int _asCount(dynamic value, {int fallback = 1}) {
if (value == null) return fallback;
if (value is int) return value;
if (value is num) return value.toInt();
final text = value.toString();
final match = RegExp(r'\d+').firstMatch(text);
if (match == null) return fallback;
return int.tryParse(match.group(0)!) ?? fallback;
}
String _stableDzikirId(String type, Map<String, dynamic> item) {
final apiId = _asString(item['id']);
if (apiId.isNotEmpty) {
return '${type}_$apiId';
}
final seed = [
type,
_asString(item['type']),
_asString(item['arab']),
_asString(item['indo']),
_asString(item['ulang']),
].join('|');
var hash = 0;
for (final unit in seed.codeUnits) {
hash = ((hash * 31) + unit) & 0x7fffffff;
}
return '${type}_$hash';
}
String _dzikirApiType(String type) {
switch (type) {
case 'petang':
return 'sore';
default:
return type;
}
}
Map<String, String> _normalizeAudioMap(dynamic audioValue) {
final audioUrl = _extractAudioUrl(audioValue);
if (audioUrl.isEmpty) return {};
return {
'01': audioUrl,
'02': audioUrl,
'03': audioUrl,
'04': audioUrl,
'05': audioUrl,
'06': audioUrl,
};
}
String _extractAudioUrl(dynamic value) {
if (value == null) return '';
if (value is String) return value.trim();
if (value is Map) {
final direct = _asString(value['url']).trim();
if (direct.isNotEmpty) return direct;
final src = _asString(value['src']).trim();
if (src.isNotEmpty) return src;
final audio = _asString(value['audio']).trim();
if (audio.isNotEmpty) return audio;
}
return '';
}
String _normalizeQariKey(dynamic rawKey) {
if (rawKey == null) return '';
if (rawKey is int) return rawKey.toString().padLeft(2, '0');
if (rawKey is num) return rawKey.toInt().toString().padLeft(2, '0');
final text = rawKey.toString().trim();
if (text.isEmpty) return '';
final digits = text.replaceAll(RegExp(r'[^0-9]'), '');
if (digits.isNotEmpty) {
final parsed = int.tryParse(digits);
if (parsed != null) return parsed.toString().padLeft(2, '0');
}
return text;
}
Map<String, String> _normalizeAyahAudioMap(dynamic audioValue) {
if (audioValue is Map) {
final normalized = <String, String>{};
audioValue.forEach((rawKey, rawValue) {
final key = _normalizeQariKey(rawKey);
final url = _extractAudioUrl(rawValue);
if (key.isNotEmpty && url.isNotEmpty) {
normalized[key] = url;
}
});
if (normalized.isNotEmpty) {
final fallbackUrl = normalized.values.first;
for (final qariId in qariNames.keys) {
normalized.putIfAbsent(qariId, () => fallbackUrl);
}
return normalized;
}
}
return _normalizeAudioMap(audioValue);
}
Map<String, dynamic> _mapSurahSummary(Map<String, dynamic> item) {
final number = _asInt(item['number']);
return {
'nomor': number,
'nama': _asString(item['name_short']),
'namaLatin': _asString(item['name_id']),
'jumlahAyat': _asInt(item['number_of_verses']),
'tempatTurun': _asString(item['revelation_id']),
'arti': _asString(item['translation_id']),
'deskripsi': _asString(item['tafsir']),
'audioFull': _normalizeAudioMap(item['audio_url']),
};
}
Map<String, dynamic> _mapAyah(Map<String, dynamic> item) {
return {
'nomorAyat': _asInt(item['ayah']),
'teksArab': _asString(item['arab']),
'teksLatin': _asString(item['latin']),
'teksIndonesia': _asString(item['text']),
'audio': _normalizeAyahAudioMap(item['audio'] ?? item['audio_url']),
'juz': _asInt(item['juz']),
'page': _asInt(item['page']),
'hizb': _asInt(item['hizb']),
'theme': _asString(item['theme']),
'asbab': _asString(item['asbab']),
'notes': _asString(item['notes']),
'surah': _asInt(item['surah']),
'ayahId': _asInt(item['id']),
};
}
Future<List<Map<String, dynamic>>> getAllSurahs() async {
if (_surahListCache != null) return _surahListCache!;
final raw = await _getData('/v1/quran/surah');
if (raw is! List) return [];
_surahListCache =
raw.whereType<Map<String, dynamic>>().map(_mapSurahSummary).toList();
return _surahListCache!;
}
Future<Map<String, dynamic>?> getSurah(int number) async {
if (_surahCache.containsKey(number)) {
return _surahCache[number];
}
final surahs = await getAllSurahs();
Map<String, dynamic>? summary;
for (final surah in surahs) {
if (surah['nomor'] == number) {
summary = surah;
break;
}
}
final rawAyah = await _getData('/v1/quran/ayah/surah?id=$number');
if (summary == null || rawAyah is! List) {
return null;
}
final mappedAyah =
rawAyah.whereType<Map<String, dynamic>>().map(_mapAyah).toList();
final mapped = {
...summary,
'ayat': mappedAyah,
};
_surahCache[number] = mapped;
return mapped;
}
Future<Map<String, dynamic>?> getDailyAyat() async {
try {
final now = DateTime.now();
final dayOfYear = now.difference(DateTime(now.year, 1, 1)).inDays;
final surahId = (dayOfYear % 114) + 1;
final surah = await getSurah(surahId);
if (surah == null) return null;
final ayat = List<Map<String, dynamic>>.from(surah['ayat'] ?? []);
if (ayat.isEmpty) return null;
final ayatIndex = dayOfYear % ayat.length;
final picked = ayat[ayatIndex];
return {
'surahName': surah['namaLatin'] ?? '',
'nomorSurah': surahId,
'nomorAyat': picked['nomorAyat'] ?? 1,
'teksArab': picked['teksArab'] ?? '',
'teksIndonesia': picked['teksIndonesia'] ?? '',
};
} catch (_) {
return null;
}
}
Future<Map<String, dynamic>?> getRandomAyat({
int? excludeSurahNumber,
int? excludeAyahNumber,
}) async {
try {
final allAyah = await getAllAyah();
if (allAyah.isEmpty) return null;
final surahs = await getAllSurahs();
if (surahs.isEmpty) return null;
final surahNames = <int, String>{
for (final surah in surahs)
_asInt(surah['nomor']): _asString(surah['namaLatin']),
};
final filtered = allAyah.where((ayah) {
final surahNumber = _asInt(ayah['surah']);
final ayahNumber = _asInt(ayah['ayah']);
final isExcluded = excludeSurahNumber != null &&
excludeAyahNumber != null &&
surahNumber == excludeSurahNumber &&
ayahNumber == excludeAyahNumber;
if (isExcluded) return false;
return _asString(ayah['arab']).trim().isNotEmpty &&
_asString(ayah['text']).trim().isNotEmpty;
}).toList();
final candidates = filtered.isNotEmpty ? filtered : allAyah;
final picked = candidates[Random().nextInt(candidates.length)];
final surahNumber = _asInt(picked['surah']);
return {
'surahName': surahNames[surahNumber] ?? '',
'nomorSurah': surahNumber,
'nomorAyat': _asInt(picked['ayah'], fallback: 1),
'teksArab': _asString(picked['arab']),
'teksIndonesia': _asString(picked['text']),
};
} catch (_) {
return null;
}
}
Future<List<Map<String, dynamic>>> getWordByWord(
int surahId, int ayahId) async {
final key = '$surahId:$ayahId';
if (_wordByWordCache.containsKey(key)) return _wordByWordCache[key]!;
final raw =
await _getData('/v1/quran/word/ayah?surahId=$surahId&ayahId=$ayahId');
if (raw is! List) return [];
final mapped = raw.whereType<Map<String, dynamic>>().map((item) {
return {
'word': _asString(item['word']),
'arab': _asString(item['arab']),
'indo': _asString(item['indo']),
};
}).toList();
_wordByWordCache[key] = mapped;
return mapped;
}
Future<List<Map<String, dynamic>>> getAllAyah() async {
if (_allAyahCache != null) return _allAyahCache!;
final raw = await _getData('/v1/quran/ayah');
if (raw is! List) return [];
_allAyahCache = raw.whereType<Map<String, dynamic>>().map((item) {
return {
'id': _asInt(item['id']),
'surah': _asInt(item['surah']),
'ayah': _asInt(item['ayah']),
'arab': _asString(item['arab']),
'latin': _asString(item['latin']),
'text': _asString(item['text']),
'juz': _asInt(item['juz']),
'page': _asInt(item['page']),
'hizb': _asInt(item['hizb']),
'theme': _asString(item['theme']),
'asbab': _asString(item['asbab']),
};
}).toList();
return _allAyahCache!;
}
Future<List<Map<String, dynamic>>> getTafsirBySurah(int surahId) async {
if (_tafsirCache == null) {
final raw = await _getData('/v1/quran/tafsir');
if (raw is! List) return [];
_tafsirCache = raw.whereType<Map<String, dynamic>>().map((item) {
return {
'id': _asInt(item['id']),
'ayah': _asInt(item['ayah']),
'wajiz': _asString(item['wajiz']),
'tahlili': _asString(item['tahlili']),
};
}).toList();
}
final allAyah = await getAllAyah();
if (allAyah.isEmpty || _tafsirCache == null) return [];
final ayahById = <int, Map<String, dynamic>>{};
final ayahBySurahAyah = <String, Map<String, dynamic>>{};
for (final ayah in allAyah) {
final id = _asInt(ayah['id']);
final surah = _asInt(ayah['surah']);
final ayahNumber = _asInt(ayah['ayah']);
ayahById[id] = ayah;
ayahBySurahAyah['$surah:$ayahNumber'] = ayah;
}
final result = <Map<String, dynamic>>[];
for (final tafsir in _tafsirCache!) {
final tafsirId = _asInt(tafsir['id']);
final tafsirAyah = _asInt(tafsir['ayah']);
Map<String, dynamic>? ayahMeta = ayahById[tafsirId];
ayahMeta ??= ayahBySurahAyah['$surahId:$tafsirAyah'];
if (ayahMeta == null) continue;
if (ayahMeta['surah'] != surahId) continue;
result.add({
'nomorAyat': _asInt(ayahMeta['ayah'], fallback: tafsirAyah),
'wajiz': tafsir['wajiz'],
'tahlili': tafsir['tahlili'],
});
}
result.sort(
(a, b) => (a['nomorAyat'] as int).compareTo(b['nomorAyat'] as int));
return result;
}
Future<List<Map<String, dynamic>>> getAsbabBySurah(int surahId) async {
if (_asbabCache == null) {
final raw = await _getData('/v1/quran/asbab');
if (raw is! List) return [];
_asbabCache = raw.whereType<Map<String, dynamic>>().map((item) {
return {
'id': _asInt(item['id']),
'ayah': _asInt(item['ayah']),
'text': _asString(item['text']),
};
}).toList();
}
final allAyah = await getAllAyah();
if (allAyah.isEmpty || _asbabCache == null) return [];
final ayahById = <int, Map<String, dynamic>>{};
final ayahBySurahAyah = <String, Map<String, dynamic>>{};
for (final ayah in allAyah) {
final id = _asInt(ayah['id']);
final surah = _asInt(ayah['surah']);
final ayahNumber = _asInt(ayah['ayah']);
ayahById[id] = ayah;
ayahBySurahAyah['$surah:$ayahNumber'] = ayah;
}
final result = <Map<String, dynamic>>[];
for (final asbab in _asbabCache!) {
final asbabId = _asInt(asbab['id']);
final asbabAyah = _asInt(asbab['ayah']);
Map<String, dynamic>? ayahMeta = ayahById[asbabId];
ayahMeta ??= ayahBySurahAyah['$surahId:$asbabAyah'];
if (ayahMeta == null) continue;
if (ayahMeta['surah'] != surahId) continue;
result.add({
'nomorAyat': _asInt(ayahMeta['ayah'], fallback: asbabAyah),
'text': asbab['text'],
});
}
result.sort(
(a, b) => (a['nomorAyat'] as int).compareTo(b['nomorAyat'] as int));
return result;
}
Future<List<Map<String, dynamic>>> getJuzList() async {
if (_juzCache != null) return _juzCache!;
final raw = await _getData('/v1/quran/juz');
if (raw is! List) return [];
_juzCache = raw.whereType<Map<String, dynamic>>().map((item) {
return {
'number': _asInt(item['number']),
'name': _asString(item['name']),
'surah_id_start': _asInt(item['surah_id_start']),
'verse_start': _asInt(item['verse_start']),
'surah_id_end': _asInt(item['surah_id_end']),
'verse_end': _asInt(item['verse_end']),
'name_start_id': _asString(item['name_start_id']),
'name_end_id': _asString(item['name_end_id']),
};
}).toList();
return _juzCache!;
}
Future<List<Map<String, dynamic>>> getAyahByPage(int page) async {
if (_pageAyahCache.containsKey(page)) return _pageAyahCache[page]!;
final raw = await _getData('/v1/quran/ayah/page?id=$page');
if (raw is! List) return [];
final mapped = raw.whereType<Map<String, dynamic>>().map((item) {
return {
'surah': _asInt(item['surah']),
'ayah': _asInt(item['ayah']),
'arab': _asString(item['arab']),
'text': _asString(item['text']),
'theme': _asString(item['theme']),
};
}).toList();
_pageAyahCache[page] = mapped;
return mapped;
}
Future<List<Map<String, dynamic>>> getThemes() async {
if (_themeCache != null) return _themeCache!;
final raw = await _getData('/v1/quran/theme');
if (raw is! List) return [];
_themeCache = raw.whereType<Map<String, dynamic>>().map((item) {
return {
'id': _asInt(item['id']),
'name': _asString(item['name']),
};
}).toList();
return _themeCache!;
}
Future<List<Map<String, dynamic>>> searchAyah(String query) async {
final q = query.trim().toLowerCase();
if (q.isEmpty) return [];
final allAyah = await getAllAyah();
final results = allAyah
.where((item) {
final text = _asString(item['text']).toLowerCase();
final latin = _asString(item['latin']).toLowerCase();
final arab = _asString(item['arab']);
return text.contains(q) ||
latin.contains(q) ||
arab.contains(query.trim());
})
.take(50)
.toList();
return results;
}
Future<List<Map<String, dynamic>>> getAsmaulHusna() async {
if (_asmaCache != null) return _asmaCache!;
final raw = await _getData('/v1/quran/asma');
if (raw is! List) return [];
_asmaCache = raw.whereType<Map<String, dynamic>>().map((item) {
return {
'id': _asInt(item['id']),
'arab': _asString(item['arab']),
'latin': _asString(item['latin']),
'indo': _asString(item['indo']),
};
}).toList();
return _asmaCache!;
}
Future<List<Map<String, dynamic>>> getDoaList({bool strict = false}) async {
if (_doaCache != null) return _doaCache!;
final raw =
strict ? await _getDataOrThrow('/v1/doa') : await _getData('/v1/doa');
if (raw is! List) {
if (strict) {
throw const MuslimApiException('Invalid doa payload');
}
return [];
}
_doaCache = raw.whereType<Map<String, dynamic>>().map((item) {
return {
'judul': _asString(item['judul']),
'arab': _asString(item['arab']),
'indo': _asString(item['indo']),
'source': _asString(item['source']),
};
}).toList();
return _doaCache!;
}
Future<List<Map<String, dynamic>>> getHaditsList(
{bool strict = false}) async {
if (_haditsCache != null) return _haditsCache!;
final raw = strict
? await _getDataOrThrow('/v1/hadits')
: await _getData('/v1/hadits');
if (raw is! List) {
if (strict) {
throw const MuslimApiException('Invalid hadits payload');
}
return [];
}
_haditsCache = raw.whereType<Map<String, dynamic>>().map((item) {
return {
'no': _asInt(item['no']),
'judul': _asString(item['judul']),
'arab': _asString(item['arab']),
'indo': _asString(item['indo']),
};
}).toList();
return _haditsCache!;
}
Future<List<Map<String, dynamic>>> getDzikirByType(
String type, {
bool strict = false,
}) async {
if (_dzikirByTypeCache.containsKey(type)) {
return _dzikirByTypeCache[type]!;
}
final apiType = _dzikirApiType(type);
final raw = strict
? await _getDataOrThrow('/v1/dzikir?type=$apiType')
: await _getData('/v1/dzikir?type=$apiType');
if (raw is! List) {
if (strict) {
throw MuslimApiException('Invalid dzikir payload for type: $type');
}
return [];
}
final mapped = <Map<String, dynamic>>[];
for (var i = 0; i < raw.length; i++) {
final item = raw[i];
if (item is! Map<String, dynamic>) continue;
mapped.add({
'id': _stableDzikirId(type, item),
'arab': _asString(item['arab']),
'indo': _asString(item['indo']),
'type': _asString(item['type']),
'ulang': _asCount(item['ulang'], fallback: 1),
});
}
_dzikirByTypeCache[type] = mapped;
return mapped;
}
}

View File

@@ -0,0 +1,39 @@
import 'dart:convert';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:intl/intl.dart';
import '../local/hive_boxes.dart';
/// Lightweight local analytics sink for notification events.
class NotificationAnalyticsService {
NotificationAnalyticsService._();
static final NotificationAnalyticsService instance =
NotificationAnalyticsService._();
Box get _box => Hive.box(HiveBoxes.notificationRuntime);
Future<void> track(
String event, {
Map<String, dynamic> dimensions = const <String, dynamic>{},
}) async {
final date = DateFormat('yyyy-MM-dd').format(DateTime.now());
final counterKey = 'analytics.$date.$event';
final current = (_box.get(counterKey) as int?) ?? 0;
await _box.put(counterKey, current + 1);
// Keep a small rolling audit buffer for debug support.
final raw = (_box.get('analytics.recent') ?? '[]').toString();
final decoded = json.decode(raw);
final list = decoded is List ? decoded : <dynamic>[];
list.add({
'event': event,
'at': DateTime.now().toIso8601String(),
'dimensions': dimensions,
});
while (list.length > 100) {
list.removeAt(0);
}
await _box.put('analytics.recent', json.encode(list));
}
}

View File

@@ -0,0 +1,299 @@
import 'package:hive_flutter/hive_flutter.dart';
import 'package:intl/intl.dart';
import '../local/hive_boxes.dart';
import '../local/models/app_settings.dart';
import '../local/models/daily_worship_log.dart';
import 'notification_inbox_service.dart';
import 'notification_runtime_service.dart';
import 'notification_service.dart';
/// Creates in-app inbox events from runtime/system conditions.
class NotificationEventProducerService {
NotificationEventProducerService._();
static final NotificationEventProducerService instance =
NotificationEventProducerService._();
final NotificationInboxService _inbox = NotificationInboxService.instance;
final NotificationRuntimeService _runtime =
NotificationRuntimeService.instance;
Future<void> emitPermissionWarningsIfNeeded({
required AppSettings settings,
required NotificationPermissionStatus permissionStatus,
}) async {
if (!settings.adhanEnabled.values.any((v) => v)) return;
final dateKey = _todayKey();
if (!permissionStatus.notificationsAllowed) {
final title = 'Izin notifikasi dinonaktifkan';
final body =
'Aktifkan izin notifikasi agar pengingat adzan dan iqamah dapat muncul.';
if (settings.inboxEnabled) {
await _inbox.addItem(
title: title,
body: body,
type: 'system',
source: 'local',
deeplink: '/settings',
dedupeKey: 'system.permission.notifications.$dateKey',
expiresAt: DateTime.now().add(const Duration(days: 2)),
);
}
await _pushSystemIfAllowed(
settings: settings,
dedupeSeed: 'push.system.permission.notifications.$dateKey',
title: title,
body: body,
);
}
if (!permissionStatus.exactAlarmAllowed) {
final title = 'Izin alarm presisi belum aktif';
final body =
'Aktifkan alarm presisi agar pengingat adzan tepat waktu di perangkat Android.';
if (settings.inboxEnabled) {
await _inbox.addItem(
title: title,
body: body,
type: 'system',
source: 'local',
deeplink: '/settings',
dedupeKey: 'system.permission.exact_alarm.$dateKey',
expiresAt: DateTime.now().add(const Duration(days: 2)),
);
}
await _pushSystemIfAllowed(
settings: settings,
dedupeSeed: 'push.system.permission.exact_alarm.$dateKey',
title: title,
body: body,
);
}
}
Future<void> emitScheduleFallback({
required AppSettings settings,
required String cityId,
required bool locationUnavailable,
}) async {
final dateKey = _todayKey();
final title = locationUnavailable
? 'Lokasi belum tersedia'
: 'Jadwal online terganggu';
final body = locationUnavailable
? 'Lokasi perangkat belum aktif. Aplikasi menggunakan lokasi default sementara.'
: 'Aplikasi memakai perhitungan lokal sementara. Pastikan internet aktif untuk jadwal paling akurat.';
final scope = locationUnavailable ? 'loc' : 'net';
final dedupe = 'system.schedule.fallback.$cityId.$dateKey.$scope';
if (settings.inboxEnabled) {
await _inbox.addItem(
title: title,
body: body,
type: 'system',
source: 'local',
deeplink: '/imsakiyah',
dedupeKey: dedupe,
expiresAt: DateTime.now().add(const Duration(days: 1)),
meta: <String, dynamic>{
'cityId': cityId,
'date': dateKey,
'scope': scope,
},
);
}
await _pushSystemIfAllowed(
settings: settings,
dedupeSeed: 'push.$dedupe',
title: title,
body: body,
);
}
Future<void> emitNotificationSyncFailed({
required AppSettings settings,
required String cityId,
}) async {
final dateKey = _todayKey();
final title = 'Sinkronisasi alarm adzan gagal';
final body =
'Pengingat adzan belum tersinkron. Coba buka aplikasi lagi atau periksa pengaturan notifikasi.';
final dedupe = 'system.notification.sync_failed.$cityId.$dateKey';
if (settings.inboxEnabled) {
await _inbox.addItem(
title: title,
body: body,
type: 'system',
source: 'local',
deeplink: '/settings',
dedupeKey: dedupe,
expiresAt: DateTime.now().add(const Duration(days: 1)),
meta: <String, dynamic>{
'cityId': cityId,
'date': dateKey,
},
);
}
await _pushSystemIfAllowed(
settings: settings,
dedupeSeed: 'push.$dedupe',
title: title,
body: body,
);
}
Future<void> emitStreakRiskIfNeeded({
required AppSettings settings,
}) async {
if (!settings.inboxEnabled || !settings.streakRiskEnabled) return;
final now = DateTime.now();
if (now.hour < 18) return;
final dateKey = _todayKey();
final worshipBox = Hive.box<DailyWorshipLog>(HiveBoxes.worshipLogs);
final log = worshipBox.get(dateKey);
if (log == null) return;
final tilawahRisk = log.tilawahLog != null && !log.tilawahLog!.isCompleted;
final dzikirRisk =
settings.trackDzikir && log.dzikirLog != null && !log.dzikirLog!.petang;
if (tilawahRisk) {
final title = 'Streak Tilawah berisiko terputus';
const body =
'Selesaikan target tilawah hari ini untuk menjaga konsistensi.';
final dedupe = 'streak.tilawah.$dateKey';
await _inbox.addItem(
title: title,
body: body,
type: 'streak_risk',
source: 'local',
deeplink: '/quran',
dedupeKey: dedupe,
expiresAt: DateTime(now.year, now.month, now.day, 23, 59),
);
await _pushHabitIfAllowed(
settings: settings,
dedupeSeed: 'push.$dedupe',
title: title,
body: body,
);
}
if (dzikirRisk) {
final title = 'Dzikir petang belum tercatat';
const body = 'Lengkapi dzikir petang untuk menjaga streak amalan harian.';
final dedupe = 'streak.dzikir.$dateKey';
await _inbox.addItem(
title: title,
body: body,
type: 'streak_risk',
source: 'local',
deeplink: '/tools/dzikir',
dedupeKey: dedupe,
expiresAt: DateTime(now.year, now.month, now.day, 23, 59),
);
await _pushHabitIfAllowed(
settings: settings,
dedupeSeed: 'push.$dedupe',
title: title,
body: body,
);
}
}
Future<void> emitWeeklySummaryIfNeeded({
required AppSettings settings,
}) async {
if (!settings.inboxEnabled || !settings.weeklySummaryEnabled) return;
final now = DateTime.now();
if (now.weekday != DateTime.monday || now.hour < 6) return;
final monday = now.subtract(Duration(days: now.weekday - 1));
final weekKey = DateFormat('yyyy-MM-dd').format(monday);
if (_runtime.lastWeeklySummaryWeekKey() == weekKey) return;
final worshipBox = Hive.box<DailyWorshipLog>(HiveBoxes.worshipLogs);
var completionDays = 0;
var totalPoints = 0;
for (int i = 1; i <= 7; i++) {
final date = now.subtract(Duration(days: i));
final key = DateFormat('yyyy-MM-dd').format(date);
final log = worshipBox.get(key);
if (log == null) continue;
if (log.completionPercent >= 70) completionDays++;
totalPoints += log.totalPoints;
}
await _inbox.addItem(
title: 'Ringkasan Ibadah Mingguan',
body:
'7 hari terakhir: $completionDays hari konsisten, total $totalPoints poin. Lihat detail laporan.',
type: 'summary',
source: 'local',
deeplink: '/laporan',
dedupeKey: 'summary.weekly.$weekKey',
expiresAt: now.add(const Duration(days: 7)),
);
await _runtime.setLastWeeklySummaryWeekKey(weekKey);
}
String _todayKey() => DateFormat('yyyy-MM-dd').format(DateTime.now());
Future<void> _pushSystemIfAllowed({
required AppSettings settings,
required String dedupeSeed,
required String title,
required String body,
}) async {
await _pushNonPrayer(
settings: settings,
dedupeSeed: dedupeSeed,
title: title,
body: body,
payloadType: 'system',
silent: true,
);
}
Future<void> _pushHabitIfAllowed({
required AppSettings settings,
required String dedupeSeed,
required String title,
required String body,
}) async {
await _pushNonPrayer(
settings: settings,
dedupeSeed: dedupeSeed,
title: title,
body: body,
payloadType: 'streak_risk',
silent: false,
);
}
Future<void> _pushNonPrayer({
required AppSettings settings,
required String dedupeSeed,
required String title,
required String body,
required String payloadType,
required bool silent,
}) async {
if (!settings.alertsEnabled) return;
final notif = NotificationService.instance;
await notif.showNonPrayerAlert(
settings: settings,
id: notif.nonPrayerNotificationId(dedupeSeed),
title: title,
body: body,
payloadType: payloadType,
silent: silent,
);
}
}

View File

@@ -0,0 +1,299 @@
import 'package:flutter/foundation.dart';
import 'package:hive_flutter/hive_flutter.dart';
import '../local/hive_boxes.dart';
import 'notification_analytics_service.dart';
class NotificationInboxItem {
const NotificationInboxItem({
required this.id,
required this.title,
required this.body,
required this.type,
required this.createdAt,
required this.expiresAt,
required this.readAt,
required this.isPinned,
required this.source,
required this.deeplink,
required this.meta,
});
final String id;
final String title;
final String body;
final String type;
final DateTime createdAt;
final DateTime? expiresAt;
final DateTime? readAt;
final bool isPinned;
final String source;
final String? deeplink;
final Map<String, dynamic> meta;
bool get isRead => readAt != null;
bool get isExpired => expiresAt != null && DateTime.now().isAfter(expiresAt!);
Map<String, dynamic> toMap() => {
'id': id,
'title': title,
'body': body,
'type': type,
'createdAt': createdAt.toIso8601String(),
'expiresAt': expiresAt?.toIso8601String(),
'readAt': readAt?.toIso8601String(),
'isPinned': isPinned,
'source': source,
'deeplink': deeplink,
'meta': meta,
};
static NotificationInboxItem fromMap(Map<dynamic, dynamic> map) {
final createdRaw = (map['createdAt'] ?? '').toString();
final expiresRaw = (map['expiresAt'] ?? '').toString();
final readRaw = (map['readAt'] ?? '').toString();
final rawMeta = map['meta'];
return NotificationInboxItem(
id: (map['id'] ?? '').toString(),
title: (map['title'] ?? '').toString(),
body: (map['body'] ?? '').toString(),
type: (map['type'] ?? 'system').toString(),
createdAt: DateTime.tryParse(createdRaw) ??
DateTime.fromMillisecondsSinceEpoch(0),
expiresAt: expiresRaw.isEmpty ? null : DateTime.tryParse(expiresRaw),
readAt: readRaw.isEmpty ? null : DateTime.tryParse(readRaw),
isPinned: map['isPinned'] == true,
source: (map['source'] ?? 'local').toString(),
deeplink: ((map['deeplink'] ?? '').toString().trim().isEmpty)
? null
: (map['deeplink'] ?? '').toString(),
meta: rawMeta is Map
? rawMeta.map((k, v) => MapEntry(k.toString(), v))
: const <String, dynamic>{},
);
}
}
class NotificationInboxService {
NotificationInboxService._();
static final NotificationInboxService instance = NotificationInboxService._();
Box get _box => Hive.box(HiveBoxes.notificationInbox);
ValueListenable<Box> listenable() => _box.listenable();
List<NotificationInboxItem> allItems({
String filter = 'all',
}) {
final items = _box.values
.whereType<Map>()
.map((raw) => NotificationInboxItem.fromMap(raw))
.where((item) => !item.isExpired)
.where((item) {
switch (filter) {
case 'unread':
return !item.isRead;
case 'system':
return item.type == 'system';
default:
return true;
}
}).toList()
..sort((a, b) {
if (a.isPinned != b.isPinned) {
return a.isPinned ? -1 : 1;
}
return b.createdAt.compareTo(a.createdAt);
});
return items;
}
int unreadCount() => allItems().where((e) => !e.isRead).length;
Future<void> addItem({
required String title,
required String body,
required String type,
String source = 'local',
String? deeplink,
String? dedupeKey,
DateTime? expiresAt,
bool isPinned = false,
Map<String, dynamic> meta = const <String, dynamic>{},
}) async {
final key = dedupeKey ?? _defaultKey(type, title, body);
if (_box.containsKey(key)) {
final existingRaw = _box.get(key);
if (existingRaw is Map) {
final existing = NotificationInboxItem.fromMap(existingRaw);
await _box.put(
key,
existing
.copyWith(
title: title,
body: body,
type: type,
source: source,
deeplink: deeplink,
expiresAt: expiresAt ?? existing.expiresAt,
isPinned: isPinned || existing.isPinned,
meta: meta.isEmpty ? existing.meta : meta,
)
.toMap(),
);
}
return;
}
final item = NotificationInboxItem(
id: key,
title: title,
body: body,
type: type,
createdAt: DateTime.now(),
expiresAt: expiresAt,
readAt: null,
isPinned: isPinned,
source: source,
deeplink: deeplink,
meta: meta,
);
await _box.put(key, item.toMap());
await NotificationAnalyticsService.instance.track(
'notif_inbox_created',
dimensions: <String, dynamic>{
'event_type': type,
'source': source,
},
);
}
Future<void> markRead(String id) async {
final raw = _box.get(id);
if (raw is! Map) return;
final item = NotificationInboxItem.fromMap(raw);
if (item.isRead) return;
await _box.put(
id,
item.copyWith(readAt: DateTime.now()).toMap(),
);
await NotificationAnalyticsService.instance.track(
'notif_mark_read',
dimensions: <String, dynamic>{'event_type': item.type},
);
}
Future<void> markUnread(String id) async {
final raw = _box.get(id);
if (raw is! Map) return;
final item = NotificationInboxItem.fromMap(raw);
if (!item.isRead) return;
await _box.put(
id,
item.copyWith(readAt: null).toMap(),
);
await NotificationAnalyticsService.instance.track(
'notif_mark_unread',
dimensions: <String, dynamic>{'event_type': item.type},
);
}
Future<void> markAllRead() async {
final updates = <dynamic, Map<String, dynamic>>{};
for (final key in _box.keys) {
final raw = _box.get(key);
if (raw is! Map) continue;
final item = NotificationInboxItem.fromMap(raw);
if (item.isRead) continue;
updates[key] = item.copyWith(readAt: DateTime.now()).toMap();
}
if (updates.isNotEmpty) {
await _box.putAll(updates);
}
}
Future<void> remove(String id) async {
await _box.delete(id);
}
Future<void> removeByType(String type) async {
final keys = <dynamic>[];
for (final key in _box.keys) {
final raw = _box.get(key);
if (raw is! Map) continue;
final item = NotificationInboxItem.fromMap(raw);
if (item.type == type) {
keys.add(key);
}
}
if (keys.isNotEmpty) {
await _box.deleteAll(keys);
}
}
Future<void> togglePinned(String id) async {
final raw = _box.get(id);
if (raw is! Map) return;
final item = NotificationInboxItem.fromMap(raw);
await _box.put(
id,
item.copyWith(isPinned: !item.isPinned).toMap(),
);
}
Future<void> removeExpired() async {
final expiredKeys = <dynamic>[];
for (final key in _box.keys) {
final raw = _box.get(key);
if (raw is! Map) continue;
final item = NotificationInboxItem.fromMap(raw);
if (item.isExpired) expiredKeys.add(key);
}
if (expiredKeys.isNotEmpty) {
await _box.deleteAll(expiredKeys);
}
}
String _defaultKey(String type, String title, String body) {
final seed = '$type|$title|$body';
var hash = 17;
for (final rune in seed.runes) {
hash = 31 * hash + rune;
}
return 'inbox_${hash.abs()}';
}
}
extension on NotificationInboxItem {
static const _readAtUnchanged = Object();
NotificationInboxItem copyWith({
String? title,
String? body,
String? type,
DateTime? createdAt,
DateTime? expiresAt,
Object? readAt = _readAtUnchanged,
bool? isPinned,
String? source,
String? deeplink,
Map<String, dynamic>? meta,
}) {
return NotificationInboxItem(
id: id,
title: title ?? this.title,
body: body ?? this.body,
type: type ?? this.type,
createdAt: createdAt ?? this.createdAt,
expiresAt: expiresAt ?? this.expiresAt,
readAt: identical(readAt, _readAtUnchanged)
? this.readAt
: readAt as DateTime?,
isPinned: isPinned ?? this.isPinned,
source: source ?? this.source,
deeplink: deeplink ?? this.deeplink,
meta: meta ?? this.meta,
);
}
}

View File

@@ -0,0 +1,24 @@
import '../local/models/app_settings.dart';
import 'notification_event_producer_service.dart';
import 'notification_inbox_service.dart';
import 'remote_notification_content_service.dart';
/// High-level coordinator for non-prayer notification flows.
class NotificationOrchestratorService {
NotificationOrchestratorService._();
static final NotificationOrchestratorService instance =
NotificationOrchestratorService._();
Future<void> runPassivePass({
required AppSettings settings,
}) async {
await NotificationInboxService.instance.removeExpired();
await NotificationEventProducerService.instance.emitStreakRiskIfNeeded(
settings: settings,
);
await NotificationEventProducerService.instance.emitWeeklySummaryIfNeeded(
settings: settings,
);
await RemoteNotificationContentService.instance.sync(settings: settings);
}
}

View File

@@ -0,0 +1,86 @@
import 'package:hive_flutter/hive_flutter.dart';
import 'package:intl/intl.dart';
import '../local/hive_boxes.dart';
import '../local/models/app_settings.dart';
/// Runtime persistence for notification counters and cursors.
class NotificationRuntimeService {
NotificationRuntimeService._();
static final NotificationRuntimeService instance =
NotificationRuntimeService._();
static const _nonPrayerCountPrefix = 'non_prayer_push_count.';
static const _lastRemoteSyncKey = 'remote.last_sync_at';
static const _lastWeeklySummaryKey = 'summary.last_week_key';
Box get _box => Hive.box(HiveBoxes.notificationRuntime);
String _todayKey() => DateFormat('yyyy-MM-dd').format(DateTime.now());
int nonPrayerPushCountToday() {
return (_box.get('$_nonPrayerCountPrefix${_todayKey()}') as int?) ?? 0;
}
Future<void> incrementNonPrayerPushCount() async {
final key = '$_nonPrayerCountPrefix${_todayKey()}';
final next = ((_box.get(key) as int?) ?? 0) + 1;
await _box.put(key, next);
}
bool isWithinQuietHours(AppSettings settings, {DateTime? now}) {
final current = now ?? DateTime.now();
final startParts = _parseHourMinute(settings.quietHoursStart);
final endParts = _parseHourMinute(settings.quietHoursEnd);
if (startParts == null || endParts == null) return false;
final currentMinutes = current.hour * 60 + current.minute;
final startMinutes = startParts.$1 * 60 + startParts.$2;
final endMinutes = endParts.$1 * 60 + endParts.$2;
if (startMinutes == endMinutes) {
// Same value means quiet-hours disabled.
return false;
}
if (startMinutes < endMinutes) {
return currentMinutes >= startMinutes && currentMinutes < endMinutes;
}
// Overnight interval (e.g. 22:00 -> 05:00).
return currentMinutes >= startMinutes || currentMinutes < endMinutes;
}
bool canSendNonPrayerPush(AppSettings settings, {DateTime? now}) {
if (!settings.alertsEnabled) return false;
if (isWithinQuietHours(settings, now: now)) return false;
return nonPrayerPushCountToday() < settings.maxNonPrayerPushPerDay;
}
DateTime? lastRemoteSyncAt() {
final raw = (_box.get(_lastRemoteSyncKey) ?? '').toString();
if (raw.isEmpty) return null;
return DateTime.tryParse(raw);
}
Future<void> setLastRemoteSyncAt(DateTime value) async {
await _box.put(_lastRemoteSyncKey, value.toIso8601String());
}
String? lastWeeklySummaryWeekKey() {
final raw = (_box.get(_lastWeeklySummaryKey) ?? '').toString();
return raw.isEmpty ? null : raw;
}
Future<void> setLastWeeklySummaryWeekKey(String key) async {
await _box.put(_lastWeeklySummaryKey, key);
}
(int, int)? _parseHourMinute(String value) {
final match = RegExp(r'^(\d{1,2}):(\d{2})$').firstMatch(value.trim());
if (match == null) return null;
final hour = int.tryParse(match.group(1) ?? '');
final minute = int.tryParse(match.group(2) ?? '');
if (hour == null || minute == null) return null;
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) return null;
return (hour, minute);
}
}

View File

@@ -1,7 +1,43 @@
import 'dart:io' show Platform;
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:timezone/data/latest.dart' as tz_data;
import 'package:timezone/timezone.dart' as tz;
/// Notification service for Adhan and Iqamah notifications.
import '../local/models/app_settings.dart';
import 'notification_analytics_service.dart';
import 'notification_runtime_service.dart';
class NotificationPermissionStatus {
const NotificationPermissionStatus({
required this.notificationsAllowed,
required this.exactAlarmAllowed,
});
final bool notificationsAllowed;
final bool exactAlarmAllowed;
}
class NotificationPendingAlert {
const NotificationPendingAlert({
required this.id,
required this.type,
required this.title,
required this.body,
required this.scheduledAt,
});
final int id;
final String type;
final String title;
final String body;
final DateTime? scheduledAt;
}
/// Notification service for Adzan and Iqamah reminders.
///
/// This service owns the local notifications setup, permission requests,
/// timezone setup, and scheduling lifecycle for prayer notifications.
class NotificationService {
NotificationService._();
static final NotificationService instance = NotificationService._();
@@ -10,16 +46,100 @@ class NotificationService {
FlutterLocalNotificationsPlugin();
bool _initialized = false;
String? _lastSyncSignature;
static const int _checklistReminderId = 920001;
/// Initialize notification channels.
static const _adhanDetails = NotificationDetails(
android: AndroidNotificationDetails(
'adhan_channel',
'Adzan Notifications',
channelDescription: 'Pengingat waktu adzan',
importance: Importance.max,
priority: Priority.high,
playSound: true,
),
iOS: DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
),
macOS: DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
),
);
static const _iqamahDetails = NotificationDetails(
android: AndroidNotificationDetails(
'iqamah_channel',
'Iqamah Reminders',
channelDescription: 'Pengingat waktu iqamah',
importance: Importance.high,
priority: Priority.high,
playSound: true,
),
iOS: DarwinNotificationDetails(
presentAlert: true,
presentSound: true,
),
macOS: DarwinNotificationDetails(
presentAlert: true,
presentSound: true,
),
);
static const _habitDetails = NotificationDetails(
android: AndroidNotificationDetails(
'habit_channel',
'Pengingat Ibadah Harian',
channelDescription: 'Pengingat checklist, streak, dan kebiasaan ibadah',
importance: Importance.high,
priority: Priority.high,
playSound: true,
),
iOS: DarwinNotificationDetails(
presentAlert: true,
presentSound: true,
),
macOS: DarwinNotificationDetails(
presentAlert: true,
presentSound: true,
),
);
static const _systemDetails = NotificationDetails(
android: AndroidNotificationDetails(
'system_channel',
'Peringatan Sistem',
channelDescription: 'Peringatan status izin dan sinkronisasi jadwal',
importance: Importance.defaultImportance,
priority: Priority.defaultPriority,
playSound: false,
),
iOS: DarwinNotificationDetails(
presentAlert: true,
presentSound: false,
),
macOS: DarwinNotificationDetails(
presentAlert: true,
presentSound: false,
),
);
/// Initialize plugin, permissions, and timezone once.
Future<void> init() async {
if (_initialized) return;
const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
tz_data.initializeTimeZones();
_configureLocalTimeZone();
const androidSettings =
AndroidInitializationSettings('@mipmap/ic_launcher');
const darwinSettings = DarwinInitializationSettings(
requestAlertPermission: true,
requestBadgePermission: true,
requestSoundPermission: true,
requestAlertPermission: false,
requestBadgePermission: false,
requestSoundPermission: false,
);
const settings = InitializationSettings(
@@ -28,71 +148,509 @@ class NotificationService {
macOS: darwinSettings,
);
await _plugin.initialize(settings);
await _plugin.initialize(settings: settings);
await _requestPermissions();
_initialized = true;
}
/// Schedule an Adhan notification at a specific time.
Future<void> scheduleAdhan({
void _configureLocalTimeZone() {
final tzId = _resolveTimeZoneIdByOffset(DateTime.now().timeZoneOffset);
try {
tz.setLocalLocation(tz.getLocation(tzId));
} catch (_) {
tz.setLocalLocation(tz.UTC);
}
}
// We prioritize Indonesian zones for better prayer scheduling defaults.
String _resolveTimeZoneIdByOffset(Duration offset) {
switch (offset.inMinutes) {
case 420:
return 'Asia/Jakarta';
case 480:
return 'Asia/Makassar';
case 540:
return 'Asia/Jayapura';
default:
if (offset.inMinutes % 60 == 0) {
final etcHours = -(offset.inMinutes ~/ 60);
final sign = etcHours >= 0 ? '+' : '';
return 'Etc/GMT$sign$etcHours';
}
return 'UTC';
}
}
Future<void> _requestPermissions() async {
if (Platform.isAndroid) {
final androidPlugin = _plugin.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>();
await androidPlugin?.requestNotificationsPermission();
await androidPlugin?.requestExactAlarmsPermission();
return;
}
if (Platform.isIOS) {
await _plugin
.resolvePlatformSpecificImplementation<
IOSFlutterLocalNotificationsPlugin>()
?.requestPermissions(alert: true, badge: true, sound: true);
return;
}
if (Platform.isMacOS) {
await _plugin
.resolvePlatformSpecificImplementation<
MacOSFlutterLocalNotificationsPlugin>()
?.requestPermissions(alert: true, badge: true, sound: true);
}
}
Future<void> syncPrayerNotifications({
required String cityId,
required Map<String, bool> adhanEnabled,
required Map<String, int> iqamahOffset,
required Map<String, Map<String, String>> schedulesByDate,
}) async {
await init();
final hasAnyEnabled = adhanEnabled.values.any((v) => v);
if (!hasAnyEnabled) {
await cancelAllPending();
_lastSyncSignature = null;
return;
}
final signature = _buildSyncSignature(
cityId, adhanEnabled, iqamahOffset, schedulesByDate);
if (_lastSyncSignature == signature) return;
await cancelAllPending();
final now = DateTime.now();
final dateEntries = schedulesByDate.entries.toList()
..sort((a, b) => a.key.compareTo(b.key));
for (final dateEntry in dateEntries) {
final date = DateTime.tryParse(dateEntry.key);
if (date == null) continue;
for (final prayerKey in const [
'subuh',
'dzuhur',
'ashar',
'maghrib',
'isya',
]) {
final canonicalPrayer = _canonicalPrayerKey(prayerKey);
if (canonicalPrayer == null) continue;
if (!(adhanEnabled[canonicalPrayer] ?? false)) continue;
final rawTime = (dateEntry.value[prayerKey] ?? '').trim();
final prayerTime = _parseScheduleDateTime(date, rawTime);
if (prayerTime == null || !prayerTime.isAfter(now)) continue;
await _scheduleAdhan(
id: _notificationId(
cityId: cityId,
dateKey: dateEntry.key,
prayerKey: canonicalPrayer,
isIqamah: false,
),
prayerName: _localizedPrayerName(canonicalPrayer),
time: prayerTime,
);
final offsetMinutes = iqamahOffset[canonicalPrayer] ?? 0;
if (offsetMinutes <= 0) continue;
final iqamahTime = prayerTime.add(Duration(minutes: offsetMinutes));
if (!iqamahTime.isAfter(now)) continue;
await _scheduleIqamah(
id: _notificationId(
cityId: cityId,
dateKey: dateEntry.key,
prayerKey: canonicalPrayer,
isIqamah: true,
),
prayerName: _localizedPrayerName(canonicalPrayer),
iqamahTime: iqamahTime,
offsetMinutes: offsetMinutes,
);
}
}
_lastSyncSignature = signature;
}
Future<void> _scheduleAdhan({
required int id,
required String prayerName,
required DateTime time,
}) async {
await _plugin.zonedSchedule(
id,
'Adhan - $prayerName',
'It\'s time for $prayerName prayer',
tz.TZDateTime.from(time, tz.local),
const NotificationDetails(
android: AndroidNotificationDetails(
'adhan_channel',
'Adhan Notifications',
channelDescription: 'Prayer time adhan notifications',
importance: Importance.high,
priority: Priority.high,
),
iOS: DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
),
),
id: id,
title: 'Adzan $prayerName',
body: 'Waktu sholat $prayerName telah masuk.',
scheduledDate: tz.TZDateTime.from(time, tz.local),
notificationDetails: _adhanDetails,
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
payload: 'adhan|$prayerName|${time.toIso8601String()}',
);
await NotificationAnalyticsService.instance.track(
'notif_push_scheduled',
dimensions: <String, dynamic>{
'event_type': 'adhan',
'prayer': prayerName,
},
);
}
/// Schedule an Iqamah reminder notification.
Future<void> scheduleIqamah({
Future<void> _scheduleIqamah({
required int id,
required String prayerName,
required DateTime adhanTime,
required DateTime iqamahTime,
required int offsetMinutes,
}) async {
final iqamahTime = adhanTime.add(Duration(minutes: offsetMinutes));
await _plugin.zonedSchedule(
id + 100, // Offset IDs for iqamah
'Iqamah - $prayerName',
'Iqamah for $prayerName in $offsetMinutes minutes',
tz.TZDateTime.from(iqamahTime, tz.local),
const NotificationDetails(
android: AndroidNotificationDetails(
'iqamah_channel',
'Iqamah Reminders',
channelDescription: 'Iqamah reminder notifications',
importance: Importance.defaultImportance,
priority: Priority.defaultPriority,
),
iOS: DarwinNotificationDetails(
presentAlert: true,
presentSound: true,
),
),
id: id,
title: 'Iqamah $prayerName',
body: 'Iqamah $prayerName dalam $offsetMinutes menit.',
scheduledDate: tz.TZDateTime.from(iqamahTime, tz.local),
notificationDetails: _iqamahDetails,
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
payload: 'iqamah|$prayerName|${iqamahTime.toIso8601String()}',
);
await NotificationAnalyticsService.instance.track(
'notif_push_scheduled',
dimensions: <String, dynamic>{
'event_type': 'iqamah',
'prayer': prayerName,
},
);
}
/// Cancel all pending notifications.
Future<void> cancelAll() async {
DateTime? _parseScheduleDateTime(DateTime date, String hhmm) {
final match = RegExp(r'^(\d{1,2}):(\d{2})').firstMatch(hhmm);
if (match == null) return null;
final hour = int.tryParse(match.group(1) ?? '');
final minute = int.tryParse(match.group(2) ?? '');
if (hour == null || minute == null) return null;
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) return null;
return DateTime(date.year, date.month, date.day, hour, minute);
}
String? _canonicalPrayerKey(String scheduleKey) {
switch (scheduleKey) {
case 'subuh':
case 'fajr':
return 'fajr';
case 'dzuhur':
case 'dhuhr':
return 'dhuhr';
case 'ashar':
case 'asr':
return 'asr';
case 'maghrib':
return 'maghrib';
case 'isya':
case 'isha':
return 'isha';
default:
return null;
}
}
String _localizedPrayerName(String canonicalPrayerKey) {
switch (canonicalPrayerKey) {
case 'fajr':
return 'Subuh';
case 'dhuhr':
return 'Dzuhur';
case 'asr':
return 'Ashar';
case 'maghrib':
return 'Maghrib';
case 'isha':
return 'Isya';
default:
return canonicalPrayerKey;
}
}
int _notificationId({
required String cityId,
required String dateKey,
required String prayerKey,
required bool isIqamah,
}) {
final seed = '$cityId|$dateKey|$prayerKey|${isIqamah ? 'iqamah' : 'adhan'}';
var hash = 17;
for (final rune in seed.runes) {
hash = 37 * hash + rune;
}
final bounded = hash.abs() % 700000;
return isIqamah ? bounded + 800000 : bounded + 100000;
}
String _buildSyncSignature(
String cityId,
Map<String, bool> adhanEnabled,
Map<String, int> iqamahOffset,
Map<String, Map<String, String>> schedulesByDate,
) {
final sortedAdhan = adhanEnabled.entries.toList()
..sort((a, b) => a.key.compareTo(b.key));
final sortedIqamah = iqamahOffset.entries.toList()
..sort((a, b) => a.key.compareTo(b.key));
final sortedDates = schedulesByDate.entries.toList()
..sort((a, b) => a.key.compareTo(b.key));
final buffer = StringBuffer(cityId);
for (final e in sortedAdhan) {
buffer.write('|${e.key}:${e.value ? 1 : 0}');
}
for (final e in sortedIqamah) {
buffer.write('|${e.key}:${e.value}');
}
for (final dateEntry in sortedDates) {
buffer.write('|${dateEntry.key}');
final times = dateEntry.value.entries.toList()
..sort((a, b) => a.key.compareTo(b.key));
for (final t in times) {
buffer.write('|${t.key}:${t.value}');
}
}
return buffer.toString();
}
Future<void> cancelAllPending() async {
try {
await _plugin.cancelAllPendingNotifications();
} catch (_) {
await _plugin.cancelAll();
}
}
Future<int> pendingCount() async {
final pending = await _plugin.pendingNotificationRequests();
return pending.length;
}
Future<void> syncHabitNotifications({
required AppSettings settings,
}) async {
await init();
if (!settings.alertsEnabled || !settings.dailyChecklistReminderEnabled) {
await cancelChecklistReminder();
return;
}
final reminderTime = settings.checklistReminderTime ?? '09:00';
final parts = _parseHourMinute(reminderTime);
if (parts == null) {
await cancelChecklistReminder();
return;
}
final now = DateTime.now();
var target = DateTime(
now.year,
now.month,
now.day,
parts.$1,
parts.$2,
);
if (!target.isAfter(now)) {
target = target.add(const Duration(days: 1));
}
await _plugin.zonedSchedule(
id: _checklistReminderId,
title: 'Checklist Ibadah Harian',
body: 'Jangan lupa perbarui progres ibadah hari ini.',
scheduledDate: tz.TZDateTime.from(target, tz.local),
notificationDetails: _habitDetails,
androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle,
matchDateTimeComponents: DateTimeComponents.time,
payload: 'checklist|daily|${target.toIso8601String()}',
);
}
Future<void> cancelChecklistReminder() async {
await _plugin.cancel(id: _checklistReminderId);
}
int nonPrayerNotificationId(String seed) {
var hash = 17;
for (final rune in seed.runes) {
hash = 41 * hash + rune;
}
return 900000 + (hash.abs() % 80000);
}
Future<bool> showNonPrayerAlert({
required AppSettings settings,
required int id,
required String title,
required String body,
String payloadType = 'system',
bool silent = false,
bool bypassQuietHours = false,
bool bypassDailyCap = false,
}) async {
await init();
final runtime = NotificationRuntimeService.instance;
if (!settings.alertsEnabled) return false;
if (!bypassQuietHours && runtime.isWithinQuietHours(settings)) return false;
if (!bypassDailyCap &&
runtime.nonPrayerPushCountToday() >= settings.maxNonPrayerPushPerDay) {
return false;
}
await _plugin.show(
id: id,
title: title,
body: body,
notificationDetails: silent ? _systemDetails : _habitDetails,
payload: '$payloadType|non_prayer|${DateTime.now().toIso8601String()}',
);
if (!bypassDailyCap) {
await runtime.incrementNonPrayerPushCount();
}
await NotificationAnalyticsService.instance.track(
'notif_push_fired',
dimensions: <String, dynamic>{
'event_type': payloadType,
'channel': 'push',
},
);
return true;
}
Future<NotificationPermissionStatus> getPermissionStatus() async {
await init();
try {
if (Platform.isAndroid) {
final androidPlugin = _plugin.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>();
final notificationsAllowed =
await androidPlugin?.areNotificationsEnabled() ?? true;
final exactAlarmAllowed =
await androidPlugin?.canScheduleExactNotifications() ?? true;
return NotificationPermissionStatus(
notificationsAllowed: notificationsAllowed,
exactAlarmAllowed: exactAlarmAllowed,
);
}
if (Platform.isIOS) {
final iosPlugin = _plugin.resolvePlatformSpecificImplementation<
IOSFlutterLocalNotificationsPlugin>();
final options = await iosPlugin?.checkPermissions();
return NotificationPermissionStatus(
notificationsAllowed: options?.isEnabled ?? true,
exactAlarmAllowed: true,
);
}
if (Platform.isMacOS) {
final macPlugin = _plugin.resolvePlatformSpecificImplementation<
MacOSFlutterLocalNotificationsPlugin>();
final options = await macPlugin?.checkPermissions();
return NotificationPermissionStatus(
notificationsAllowed: options?.isEnabled ?? true,
exactAlarmAllowed: true,
);
}
} catch (_) {
// Fallback to non-blocking defaults if platform query fails.
}
return const NotificationPermissionStatus(
notificationsAllowed: true,
exactAlarmAllowed: true,
);
}
Future<List<NotificationPendingAlert>> pendingAlerts() async {
final pending = await _plugin.pendingNotificationRequests();
final alerts = pending.map(_mapPendingRequest).toList()
..sort((a, b) {
final aTime = a.scheduledAt;
final bTime = b.scheduledAt;
if (aTime == null && bTime == null) return a.id.compareTo(b.id);
if (aTime == null) return 1;
if (bTime == null) return -1;
return aTime.compareTo(bTime);
});
return alerts;
}
NotificationPendingAlert _mapPendingRequest(PendingNotificationRequest raw) {
final payload = raw.payload ?? '';
final parts = payload.split('|');
if (parts.length >= 3) {
final type = parts[0].trim().toLowerCase();
final title = raw.title ?? '${_labelForType(type)}${parts[1].trim()}';
final body = raw.body ?? '';
final scheduledAt = DateTime.tryParse(parts[2].trim());
return NotificationPendingAlert(
id: raw.id,
type: type,
title: title,
body: body,
scheduledAt: scheduledAt,
);
}
final fallbackType = _inferTypeFromTitle(raw.title ?? '');
return NotificationPendingAlert(
id: raw.id,
type: fallbackType,
title: raw.title ?? 'Pengingat',
body: raw.body ?? '',
scheduledAt: null,
);
}
String _inferTypeFromTitle(String title) {
final normalized = title.toLowerCase();
if (normalized.contains('iqamah')) return 'iqamah';
if (normalized.contains('adzan')) return 'adhan';
return 'alert';
}
String _labelForType(String type) {
switch (type) {
case 'adhan':
return 'Adzan';
case 'iqamah':
return 'Iqamah';
case 'checklist':
return 'Checklist';
case 'streak_risk':
return 'Streak';
case 'system':
return 'Sistem';
default:
return 'Pengingat';
}
}
(int, int)? _parseHourMinute(String hhmm) {
final match = RegExp(r'^(\d{1,2}):(\d{2})$').firstMatch(hhmm.trim());
if (match == null) return null;
final hour = int.tryParse(match.group(1) ?? '');
final minute = int.tryParse(match.group(2) ?? '');
if (hour == null || minute == null) return null;
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) return null;
return (hour, minute);
}
}

View File

@@ -0,0 +1,104 @@
import 'dart:convert';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:http/http.dart' as http;
import '../local/models/app_settings.dart';
import 'notification_inbox_service.dart';
import 'notification_runtime_service.dart';
import 'notification_service.dart';
/// Pulls server-defined notification content and maps it to local inbox items.
class RemoteNotificationContentService {
RemoteNotificationContentService._();
static final RemoteNotificationContentService instance =
RemoteNotificationContentService._();
final NotificationInboxService _inbox = NotificationInboxService.instance;
final NotificationRuntimeService _runtime =
NotificationRuntimeService.instance;
Future<void> sync({
required AppSettings settings,
}) async {
if (!settings.inboxEnabled) return;
final endpoint = (dotenv.env['NOTIFICATION_FEED_URL'] ?? '').trim();
if (endpoint.isEmpty) return;
final now = DateTime.now();
final lastSync = _runtime.lastRemoteSyncAt();
if (lastSync != null &&
now.difference(lastSync) < const Duration(hours: 6)) {
return;
}
try {
final response = await http.get(Uri.parse(endpoint));
if (response.statusCode < 200 || response.statusCode >= 300) return;
final decoded = json.decode(response.body);
final items = _extractItems(decoded);
if (items.isEmpty) return;
for (final raw in items) {
final id = (raw['id'] ?? '').toString().trim();
final title = (raw['title'] ?? '').toString().trim();
final body = (raw['body'] ?? '').toString().trim();
if (id.isEmpty || title.isEmpty || body.isEmpty) continue;
final deeplink = (raw['deeplink'] ?? '').toString().trim();
final type = (raw['type'] ?? 'content').toString().trim();
final expiresAt =
DateTime.tryParse((raw['expiresAt'] ?? '').toString().trim());
final isPinned = raw['isPinned'] == true;
final shouldPush = raw['push'] == true;
await _inbox.addItem(
title: title,
body: body,
type: type.isEmpty ? 'content' : type,
source: 'remote',
deeplink: deeplink.isEmpty ? null : deeplink,
dedupeKey: 'remote.$id',
expiresAt: expiresAt,
isPinned: isPinned,
meta: <String, dynamic>{'remoteId': id},
);
if (shouldPush && settings.alertsEnabled) {
final notif = NotificationService.instance;
await notif.showNonPrayerAlert(
settings: settings,
id: notif.nonPrayerNotificationId('remote.push.$id'),
title: title,
body: body,
payloadType: 'content',
silent: true,
);
}
}
await _runtime.setLastRemoteSyncAt(now);
} catch (_) {
// Non-fatal: remote feed is optional.
}
}
List<Map<String, dynamic>> _extractItems(dynamic decoded) {
if (decoded is List) {
return decoded.whereType<Map>().map(_toStringKeyedMap).toList();
}
if (decoded is Map) {
final list = decoded['items'];
if (list is List) {
return list.whereType<Map>().map(_toStringKeyedMap).toList();
}
}
return const <Map<String, dynamic>>[];
}
Map<String, dynamic> _toStringKeyedMap(Map raw) {
return raw.map((key, value) => MapEntry(key.toString(), value));
}
}

View File

@@ -0,0 +1,47 @@
import '../local/models/app_settings.dart';
import 'notification_inbox_service.dart';
/// Phase-4 bridge for future FCM/APNs wiring.
///
/// This app currently ships without Firebase/APNs SDK setup in source control.
/// Once push SDK is configured, route incoming payloads to [ingestPayload].
class RemotePushService {
RemotePushService._();
static final RemotePushService instance = RemotePushService._();
final NotificationInboxService _inbox = NotificationInboxService.instance;
Future<void> init() async {
// Reserved for SDK wiring (FCM/APNs token registration, topic subscription).
}
Future<void> ingestPayload(
Map<String, dynamic> payload, {
AppSettings? settings,
}) async {
if (settings != null && !settings.inboxEnabled) return;
final id = (payload['id'] ?? payload['messageId'] ?? '').toString().trim();
final title = (payload['title'] ?? '').toString().trim();
final body = (payload['body'] ?? '').toString().trim();
if (id.isEmpty || title.isEmpty || body.isEmpty) return;
final type = (payload['type'] ?? 'content').toString().trim();
final deeplink = (payload['deeplink'] ?? '').toString().trim();
final expiresAt =
DateTime.tryParse((payload['expiresAt'] ?? '').toString().trim());
final isPinned = payload['isPinned'] == true;
await _inbox.addItem(
title: title,
body: body,
type: type.isEmpty ? 'content' : type,
source: 'remote',
deeplink: deeplink.isEmpty ? null : deeplink,
dedupeKey: 'remote.push.$id',
expiresAt: expiresAt,
isPinned: isPinned,
meta: <String, dynamic>{'remoteId': id},
);
}
}

View File

@@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:intl/intl.dart';
import '../../../app/theme/app_colors.dart';
import '../../../core/widgets/notification_bell_button.dart';
import '../../../core/widgets/progress_bar.dart';
import '../../../data/local/hive_boxes.dart';
import '../../../data/local/models/app_settings.dart';
@@ -27,7 +28,13 @@ class _ChecklistScreenState extends ConsumerState<ChecklistScreen> {
late Box<AppSettings> _settingsBox;
late AppSettings _settings;
final List<String> _fardhuPrayers = ['Subuh', 'Dzuhur', 'Ashar', 'Maghrib', 'Isya'];
final List<String> _fardhuPrayers = [
'Subuh',
'Dzuhur',
'Ashar',
'Maghrib',
'Isya'
];
@override
void initState() {
@@ -69,7 +76,8 @@ class _ChecklistScreenState extends ConsumerState<ChecklistScreen> {
final log = _todayLog;
// Lazily attach Dzikir and Puasa if user toggles them mid-day
if (_settings.trackDzikir && log.dzikirLog == null) log.dzikirLog = DzikirLog();
if (_settings.trackDzikir && log.dzikirLog == null)
log.dzikirLog = DzikirLog();
if (_settings.trackPuasa && log.puasaLog == null) log.puasaLog = PuasaLog();
int total = 0;
@@ -155,17 +163,16 @@ class _ChecklistScreenState extends ConsumerState<ChecklistScreen> {
Text(
DateFormat('EEEE, d MMM yyyy').format(DateTime.now()),
style: theme.textTheme.bodySmall?.copyWith(
color: isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight,
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
),
],
),
centerTitle: false,
actions: [
IconButton(
onPressed: () {},
icon: const Icon(LucideIcons.bell),
),
const NotificationBellButton(),
IconButton(
onPressed: () => context.push('/settings'),
icon: const Icon(LucideIcons.settings),
@@ -246,14 +253,16 @@ class _ChecklistScreenState extends ConsumerState<ChecklistScreen> {
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: AppColors.primary.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
const Icon(LucideIcons.star, color: AppColors.primary, size: 14),
const Icon(LucideIcons.star,
color: AppColors.primary, size: 14),
const SizedBox(width: 4),
Text(
'${log.totalPoints} pts',
@@ -334,7 +343,9 @@ class _ChecklistScreenState extends ConsumerState<ChecklistScreen> {
border: Border.all(
color: isCompleted
? AppColors.primary.withValues(alpha: 0.3)
: (isDark ? AppColors.primary.withValues(alpha: 0.08) : AppColors.cream),
: (isDark
? AppColors.primary.withValues(alpha: 0.08)
: AppColors.cream),
),
),
child: Theme(
@@ -347,10 +358,14 @@ class _ChecklistScreenState extends ConsumerState<ChecklistScreen> {
decoration: BoxDecoration(
color: isCompleted
? AppColors.primary.withValues(alpha: 0.15)
: (isDark ? AppColors.primary.withValues(alpha: 0.08) : AppColors.cream.withValues(alpha: 0.5)),
: (isDark
? AppColors.primary.withValues(alpha: 0.08)
: AppColors.cream.withValues(alpha: 0.5)),
borderRadius: BorderRadius.circular(12),
),
child: Icon(LucideIcons.building, size: 22, color: isCompleted ? AppColors.primary : AppColors.sage),
child: Icon(LucideIcons.building,
size: 22,
color: isCompleted ? AppColors.primary : AppColors.sage),
),
title: Text(
'Sholat $prayerName',
@@ -362,7 +377,9 @@ class _ChecklistScreenState extends ConsumerState<ChecklistScreen> {
),
),
subtitle: log.location != null
? Text('Di ${log.location}', style: const TextStyle(fontSize: 12, color: AppColors.primary))
? Text('Di ${log.location}',
style:
const TextStyle(fontSize: 12, color: AppColors.primary))
: null,
trailing: _CustomCheckbox(
value: isCompleted,
@@ -371,14 +388,17 @@ class _ChecklistScreenState extends ConsumerState<ChecklistScreen> {
_recalculateProgress();
},
),
childrenPadding: const EdgeInsets.only(left: 16, right: 16, bottom: 16),
childrenPadding:
const EdgeInsets.only(left: 16, right: 16, bottom: 16),
children: [
const Divider(),
const SizedBox(height: 8),
// Location Radio
Row(
children: [
const Text('Pelaksanaan:', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600)),
const Text('Pelaksanaan:',
style:
TextStyle(fontSize: 13, fontWeight: FontWeight.w600)),
const SizedBox(width: 16),
_radioOption('Masjid', log, () {
log.location = 'Masjid';
@@ -422,7 +442,9 @@ class _ChecklistScreenState extends ConsumerState<ChecklistScreen> {
color: selected ? AppColors.primary : Colors.grey,
),
const SizedBox(width: 4),
Text(title, style: TextStyle(fontSize: 13, color: selected ? AppColors.primary : null)),
Text(title,
style: TextStyle(
fontSize: 13, color: selected ? AppColors.primary : null)),
],
),
);
@@ -453,7 +475,9 @@ class _ChecklistScreenState extends ConsumerState<ChecklistScreen> {
border: Border.all(
color: log.isCompleted
? AppColors.primary.withValues(alpha: 0.3)
: (isDark ? AppColors.primary.withValues(alpha: 0.08) : AppColors.cream),
: (isDark
? AppColors.primary.withValues(alpha: 0.08)
: AppColors.cream),
),
),
child: Column(
@@ -467,10 +491,15 @@ class _ChecklistScreenState extends ConsumerState<ChecklistScreen> {
decoration: BoxDecoration(
color: log.isCompleted
? AppColors.primary.withValues(alpha: 0.15)
: (isDark ? AppColors.primary.withValues(alpha: 0.08) : AppColors.cream.withValues(alpha: 0.5)),
: (isDark
? AppColors.primary.withValues(alpha: 0.08)
: AppColors.cream.withValues(alpha: 0.5)),
borderRadius: BorderRadius.circular(12),
),
child: Icon(LucideIcons.bookOpen, size: 22, color: log.isCompleted ? AppColors.primary : AppColors.sage),
child: Icon(LucideIcons.bookOpen,
size: 22,
color:
log.isCompleted ? AppColors.primary : AppColors.sage),
),
const SizedBox(width: 14),
Expanded(
@@ -482,13 +511,17 @@ class _ChecklistScreenState extends ConsumerState<ChecklistScreen> {
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: log.isCompleted && isDark ? AppColors.textSecondaryDark : null,
decoration: log.isCompleted ? TextDecoration.lineThrough : null,
color: log.isCompleted && isDark
? AppColors.textSecondaryDark
: null,
decoration:
log.isCompleted ? TextDecoration.lineThrough : null,
),
),
Text(
'Target: ${log.targetValue} ${log.targetUnit}',
style: const TextStyle(fontSize: 12, color: AppColors.primary),
style: const TextStyle(
fontSize: 12, color: AppColors.primary),
),
],
),
@@ -516,14 +549,17 @@ class _ChecklistScreenState extends ConsumerState<ChecklistScreen> {
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight,
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
),
),
if (log.autoSync)
Tooltip(
message: 'Sinkron dari Al-Quran',
child: Icon(LucideIcons.refreshCw, size: 16, color: AppColors.primary),
child: Icon(LucideIcons.refreshCw,
size: 16, color: AppColors.primary),
),
IconButton(
icon: const Icon(LucideIcons.minusCircle, size: 20),
@@ -536,7 +572,8 @@ class _ChecklistScreenState extends ConsumerState<ChecklistScreen> {
: null,
),
IconButton(
icon: const Icon(LucideIcons.plusCircle, size: 20, color: AppColors.primary),
icon: const Icon(LucideIcons.plusCircle,
size: 20, color: AppColors.primary),
visualDensity: VisualDensity.compact,
onPressed: () {
log.rawAyatRead++;
@@ -568,7 +605,8 @@ class _ChecklistScreenState extends ConsumerState<ChecklistScreen> {
children: [
Icon(LucideIcons.sparkles, size: 20, color: AppColors.sage),
const SizedBox(width: 8),
const Text('Dzikir Harian', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 15)),
const Text('Dzikir Harian',
style: TextStyle(fontWeight: FontWeight.w600, fontSize: 15)),
],
),
const SizedBox(height: 12),
@@ -599,13 +637,17 @@ class _ChecklistScreenState extends ConsumerState<ChecklistScreen> {
children: [
const Icon(LucideIcons.moonStar, size: 20, color: AppColors.sage),
const SizedBox(width: 8),
const Expanded(child: Text('Puasa Sunnah', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 15))),
const Expanded(
child: Text('Puasa Sunnah',
style: TextStyle(fontWeight: FontWeight.w600, fontSize: 15))),
DropdownButton<String>(
value: log.jenisPuasa,
hint: const Text('Jenis', style: TextStyle(fontSize: 12)),
underline: const SizedBox(),
items: ['Senin', 'Kamis', 'Ayyamul Bidh', 'Daud', 'Lainnya']
.map((e) => DropdownMenuItem(value: e, child: Text(e, style: const TextStyle(fontSize: 13))))
.map((e) => DropdownMenuItem(
value: e,
child: Text(e, style: const TextStyle(fontSize: 13))))
.toList(),
onChanged: (v) {
log.jenisPuasa = v;
@@ -644,7 +686,9 @@ class _CustomCheckbox extends StatelessWidget {
borderRadius: BorderRadius.circular(6),
border: value ? null : Border.all(color: Colors.grey, width: 2),
),
child: value ? const Icon(LucideIcons.check, size: 16, color: Colors.white) : null,
child: value
? const Icon(LucideIcons.check, size: 16, color: Colors.white)
: null,
),
);
}

View File

@@ -1,7 +1,12 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:intl/intl.dart';
import '../../../data/services/notification_orchestrator_service.dart';
import '../../../data/services/notification_event_producer_service.dart';
import '../../../data/services/myquran_sholat_service.dart';
import '../../../data/services/notification_service.dart';
import '../../../data/services/prayer_service.dart';
import '../../../data/services/location_service.dart';
import '../../../data/local/hive_boxes.dart';
@@ -25,7 +30,8 @@ class DaySchedule {
final String province;
final String date; // yyyy-MM-dd
final String tanggal; // formatted date from API
final Map<String, String> times; // {imsak, subuh, terbit, dhuha, dzuhur, ashar, maghrib, isya}
final Map<String, String>
times; // {imsak, subuh, terbit, dhuha, dzuhur, ashar, maghrib, isya}
DaySchedule({
required this.cityName,
@@ -65,7 +71,8 @@ class DaySchedule {
activeIndex = 1; // 0=Imsak, 1=Subuh
} else {
for (int i = 0; i < prayers.length; i++) {
if (prayers[i].time != '-' && prayers[i].time.compareTo(currentTime) > 0) {
if (prayers[i].time != '-' &&
prayers[i].time.compareTo(currentTime) > 0) {
activeIndex = i;
break;
}
@@ -136,8 +143,8 @@ final prayerTimesProvider = FutureProvider<DaySchedule?>((ref) async {
final tomorrow = DateTime.now().add(const Duration(days: 1));
final tomorrowStr = DateFormat('yyyy-MM-dd').format(tomorrow);
final tmrwJadwal =
await MyQuranSholatService.instance.getDailySchedule(cityId, tomorrowStr);
final tmrwJadwal = await MyQuranSholatService.instance
.getDailySchedule(cityId, tomorrowStr);
if (tmrwJadwal != null) {
final cityInfo = await MyQuranSholatService.instance.getCityInfo(cityId);
@@ -152,37 +159,106 @@ final prayerTimesProvider = FutureProvider<DaySchedule?>((ref) async {
}
if (schedule != null) {
unawaited(_syncAdhanNotifications(cityId, schedule));
return schedule;
}
// Fallback to adhan package
final position = await LocationService.instance.getCurrentLocation();
double lat = position?.latitude ?? -6.2088;
double lng = position?.longitude ?? 106.8456;
final lat = position?.latitude ?? -6.2088;
final lng = position?.longitude ?? 106.8456;
final locationUnavailable = position == null;
final result = PrayerService.instance.getPrayerTimes(lat, lng, DateTime.now());
if (result != null) {
final result =
PrayerService.instance.getPrayerTimes(lat, lng, DateTime.now());
final timeFormat = DateFormat('HH:mm');
return DaySchedule(
final fallbackSchedule = DaySchedule(
cityName: 'Jakarta',
province: 'DKI Jakarta',
date: today,
tanggal: DateFormat('EEEE, dd/MM/yyyy').format(DateTime.now()),
times: {
'imsak': timeFormat.format(result.fajr.subtract(const Duration(minutes: 10))),
'imsak':
timeFormat.format(result.fajr.subtract(const Duration(minutes: 10))),
'subuh': timeFormat.format(result.fajr),
'terbit': timeFormat.format(result.sunrise),
'dhuha': timeFormat.format(result.sunrise.add(const Duration(minutes: 15))),
'dhuha':
timeFormat.format(result.sunrise.add(const Duration(minutes: 15))),
'dzuhur': timeFormat.format(result.dhuhr),
'ashar': timeFormat.format(result.asr),
'maghrib': timeFormat.format(result.maghrib),
'isya': timeFormat.format(result.isha),
},
);
unawaited(
NotificationEventProducerService.instance.emitScheduleFallback(
settings: Hive.box<AppSettings>(HiveBoxes.settings).get('default') ??
AppSettings(),
cityId: cityId,
locationUnavailable: locationUnavailable,
),
);
unawaited(_syncAdhanNotifications(cityId, fallbackSchedule));
return fallbackSchedule;
});
Future<void> _syncAdhanNotifications(
String cityId, DaySchedule schedule) async {
try {
final settingsBox = Hive.box<AppSettings>(HiveBoxes.settings);
final settings = settingsBox.get('default') ?? AppSettings();
final adhanEnabled = settings.adhanEnabled.values.any((v) => v);
if (adhanEnabled) {
final permissionStatus =
await NotificationService.instance.getPermissionStatus();
await NotificationEventProducerService.instance
.emitPermissionWarningsIfNeeded(
settings: settings,
permissionStatus: permissionStatus,
);
}
return null;
});
final schedulesByDate = <String, Map<String, String>>{
schedule.date: schedule.times,
};
final baseDate = DateTime.tryParse(schedule.date);
if (baseDate != null) {
final nextDate = DateFormat('yyyy-MM-dd')
.format(baseDate.add(const Duration(days: 1)));
if (!schedulesByDate.containsKey(nextDate)) {
final nextSchedule = await MyQuranSholatService.instance
.getDailySchedule(cityId, nextDate);
if (nextSchedule != null) {
schedulesByDate[nextDate] = nextSchedule;
}
}
}
await NotificationService.instance.syncPrayerNotifications(
cityId: cityId,
adhanEnabled: settings.adhanEnabled,
iqamahOffset: settings.iqamahOffset,
schedulesByDate: schedulesByDate,
);
await NotificationService.instance.syncHabitNotifications(
settings: settings,
);
await NotificationOrchestratorService.instance.runPassivePass(
settings: settings,
);
} catch (_) {
// Don't block UI when scheduling notifications fails.
unawaited(
NotificationEventProducerService.instance.emitNotificationSyncFailed(
settings: Hive.box<AppSettings>(HiveBoxes.settings).get('default') ??
AppSettings(),
cityId: cityId,
),
);
}
}
/// Provider for monthly prayer schedule (for Imsakiyah screen).
final monthlyScheduleProvider =

View File

@@ -5,13 +5,18 @@ import 'package:intl/intl.dart';
import 'package:go_router/go_router.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../../../app/icons/app_icons.dart';
import '../../../app/theme/app_colors.dart';
import '../../../core/widgets/arabic_text.dart';
import '../../../core/widgets/ayat_today_card.dart';
import '../../../core/widgets/notification_bell_button.dart';
import '../../../core/widgets/prayer_time_card.dart';
import '../../../core/widgets/tool_card.dart';
import '../../../data/local/hive_boxes.dart';
import '../../../data/local/models/app_settings.dart';
import '../../../data/local/models/daily_worship_log.dart';
import '../../../data/services/equran_service.dart';
import '../../../data/local/models/quran_bookmark.dart';
import '../../../data/services/notification_service.dart';
import '../data/prayer_times_provider.dart';
class DashboardScreen extends ConsumerStatefulWidget {
@@ -25,8 +30,10 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
Timer? _countdownTimer;
final ValueNotifier<Duration> _countdown = ValueNotifier(Duration.zero);
final ValueNotifier<String> _nextPrayerName = ValueNotifier('');
final ValueNotifier<String> _nextPrayerTime = ValueNotifier('');
final ScrollController _prayerScrollController = ScrollController();
bool _hasAutoScrolled = false;
String? _lastAutoScrollPrayerKey;
DaySchedule? _currentSchedule;
bool get _isSimpleMode {
@@ -35,17 +42,55 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
return settings?.simpleMode ?? false;
}
bool get _isAdhanEnabled {
final box = Hive.box<AppSettings>(HiveBoxes.settings);
final settings = box.get('default');
return settings?.adhanEnabled.values.any((v) => v) ?? false;
}
Future<void> _toggleAdhanFromHero() async {
final box = Hive.box<AppSettings>(HiveBoxes.settings);
final settings = box.get('default') ?? AppSettings();
final nextEnabled = !settings.adhanEnabled.values.any((v) => v);
settings.adhanEnabled.updateAll((key, _) => nextEnabled);
await settings.save();
if (!nextEnabled) {
await NotificationService.instance.cancelAllPending();
}
ref.invalidate(prayerTimesProvider);
unawaited(ref.read(prayerTimesProvider.future));
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
nextEnabled
? 'Notifikasi adzan diaktifkan'
: 'Notifikasi adzan dinonaktifkan',
),
),
);
setState(() {});
}
@override
void dispose() {
_countdownTimer?.cancel();
_prayerScrollController.dispose();
_countdown.dispose();
_nextPrayerName.dispose();
_nextPrayerTime.dispose();
super.dispose();
}
void _startCountdown(DaySchedule schedule) {
if (_currentSchedule == schedule) return;
if (_currentSchedule?.date != schedule.date) {
_hasAutoScrolled = false;
_lastAutoScrollPrayerKey = null;
}
_currentSchedule = schedule;
_countdownTimer?.cancel();
@@ -56,21 +101,68 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
}
void _updateCountdown(DaySchedule schedule) {
final next = schedule.nextPrayer;
if (next != null && next.time != '-') {
final parts = next.time.split(':');
if (parts.length == 2) {
final now = DateTime.now();
var target = DateTime(now.year, now.month, now.day,
int.parse(parts[0]), int.parse(parts[1]));
if (target.isBefore(now)) {
target = target.add(const Duration(days: 1));
final next = _resolveNextPrayer(schedule, now);
if (next == null) {
_nextPrayerName.value = '';
_nextPrayerTime.value = '';
_countdown.value = Duration.zero;
return;
}
_nextPrayerName.value = next.name;
final diff = target.difference(now);
_nextPrayerTime.value = next.time;
final diff = next.target.difference(now);
_countdown.value = diff.isNegative ? Duration.zero : diff;
}
({String name, String time, DateTime target})? _resolveNextPrayer(
DaySchedule schedule, DateTime now) {
final scheduleDate = DateTime.tryParse(schedule.date) ??
DateTime(now.year, now.month, now.day);
final entries = <({String name, String time, DateTime target})>[];
const orderedPrayers = <MapEntry<String, String>>[
MapEntry('Subuh', 'subuh'),
MapEntry('Dzuhur', 'dzuhur'),
MapEntry('Ashar', 'ashar'),
MapEntry('Maghrib', 'maghrib'),
MapEntry('Isya', 'isya'),
];
for (final prayer in orderedPrayers) {
final time = (schedule.times[prayer.value] ?? '').trim();
final parts = _parseHourMinute(time);
if (parts == null) continue;
final target = DateTime(scheduleDate.year, scheduleDate.month,
scheduleDate.day, parts.$1, parts.$2);
entries.add((name: prayer.key, time: time, target: target));
}
if (entries.isEmpty) return null;
for (final entry in entries) {
if (!entry.target.isBefore(now)) {
return entry;
}
}
final first = entries.first;
return (
name: first.name,
time: first.time,
target: first.target.add(const Duration(days: 1)),
);
}
(int, int)? _parseHourMinute(String value) {
final match = RegExp(r'(\d{1,2}):(\d{2})').firstMatch(value);
if (match == null) return null;
final hour = int.tryParse(match.group(1) ?? '');
final minute = int.tryParse(match.group(2) ?? '');
if (hour == null || minute == null) return null;
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) return null;
return (hour, minute);
}
String _formatCountdown(Duration d) {
@@ -125,14 +217,14 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
return _buildPrayerTimesSection(context, prayerTimesAsync);
},
),
const SizedBox(height: 24),
_buildLastReadQuranCard(context, isDark),
// Checklist & Weekly Progress (hidden in Simple Mode)
if (!_isSimpleMode) ...[
const SizedBox(height: 24),
_buildChecklistSummary(context, isDark),
const SizedBox(height: 24),
_buildWeeklyProgress(context, isDark),
] else ...[
const SizedBox(height: 24),
_buildQuickActions(context, isDark),
const SizedBox(height: 24),
_buildAyatHariIni(context, isDark),
@@ -145,6 +237,184 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
);
}
String _quranReadingRoute(QuranBookmark bookmark) {
final base = _isSimpleMode ? '/quran' : '/tools/quran';
return '$base/${bookmark.surahId}?startVerse=${bookmark.verseId}';
}
Widget _buildLastReadQuranCard(BuildContext context, bool isDark) {
return ValueListenableBuilder<Box<QuranBookmark>>(
valueListenable:
Hive.box<QuranBookmark>(HiveBoxes.bookmarks).listenable(),
builder: (context, box, _) {
final lastRead = box.values
.where((bookmark) => bookmark.isLastRead)
.toList()
..sort((a, b) => b.savedAt.compareTo(a.savedAt));
if (lastRead.isEmpty) {
return const SizedBox.shrink();
}
final bookmark = lastRead.first;
final arabic = bookmark.verseText.trim();
final translation = (bookmark.verseTranslation ?? '').trim();
final dateLabel = DateFormat('dd MMM • HH:mm').format(bookmark.savedAt);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'LANJUTKAN TILAWAH',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
letterSpacing: 1.5,
color: AppColors.sage,
),
),
const SizedBox(height: 12),
InkWell(
onTap: () => context.push(_quranReadingRoute(bookmark)),
borderRadius: BorderRadius.circular(20),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color:
isDark ? AppColors.surfaceDark : AppColors.surfaceLight,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: AppColors.primary.withValues(alpha: 0.22),
),
boxShadow: [
BoxShadow(
color: AppColors.primary
.withValues(alpha: isDark ? 0.10 : 0.08),
blurRadius: 18,
offset: const Offset(0, 8),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: AppColors.primary.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(14),
),
child: const Icon(
LucideIcons.bookOpen,
size: 20,
color: AppColors.primary,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'QS. ${bookmark.surahName}: ${bookmark.verseId}',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 4),
Text(
'Terakhir dibaca $dateLabel',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
),
],
),
),
Icon(
LucideIcons.chevronRight,
size: 18,
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
],
),
if (arabic.isNotEmpty) ...[
const SizedBox(height: 16),
ArabicText(
arabic,
baseFontSize: 21,
height: 1.75,
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.right,
),
],
if (translation.isNotEmpty) ...[
const SizedBox(height: 10),
Text(
translation,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13,
height: 1.5,
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
),
],
const SizedBox(height: 16),
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 14),
decoration: BoxDecoration(
color: AppColors.primary.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(999),
),
child: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
LucideIcons.bookMarked,
size: 16,
color: AppColors.primary,
),
SizedBox(width: 8),
Text(
'Lanjutkan Membaca',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: AppColors.primary,
),
),
],
),
),
],
),
),
),
const SizedBox(height: 24),
],
);
},
);
}
Widget _buildHeader(BuildContext context, bool isDark) {
return Row(
children: [
@@ -156,7 +426,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
border: Border.all(color: AppColors.primary, width: 2),
color: AppColors.primary.withValues(alpha: 0.2),
),
child: const Icon(LucideIcons.user, size: 20, color: AppColors.primary),
child:
const Icon(LucideIcons.user, size: 20, color: AppColors.primary),
),
const SizedBox(width: 12),
Expanded(
@@ -182,19 +453,15 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
),
Row(
children: [
IconButton(
onPressed: () {},
icon: Icon(
LucideIcons.bell,
color: isDark
NotificationBellButton(
iconColor: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
),
IconButton(
onPressed: () => context.push('/settings'),
icon: Icon(
LucideIcons.settings,
icon: AppIcon(
glyph: AppIcons.settings,
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
@@ -207,8 +474,10 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
}
Widget _buildHeroCard(BuildContext context, DaySchedule schedule) {
final next = schedule.nextPrayer;
final time = next?.time ?? '--:--';
final initialNext = _resolveNextPrayer(schedule, DateTime.now());
final fallbackPrayerName = initialNext?.name ?? 'Isya';
final fallbackTime = initialNext?.time ?? '--:--';
final isAdhanEnabled = _isAdhanEnabled;
return Container(
width: double.infinity,
@@ -225,16 +494,20 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
],
),
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned(
top: -20,
right: -20,
child: Container(
width: 120,
height: 120,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white.withValues(alpha: 0.15),
top: -6,
right: -4,
child: IgnorePointer(
child: Opacity(
opacity: 0.22,
child: Image.asset(
'assets/images/blob.png',
width: 140,
height: 140,
fit: BoxFit.contain,
),
),
),
),
@@ -259,12 +532,15 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
],
),
const SizedBox(height: 8),
ValueListenableBuilder<String>(
valueListenable: _nextPrayerName,
builder: (context, prayerName, _) {
final name = prayerName.isNotEmpty
? prayerName
: (next?.name ?? 'Isya');
AnimatedBuilder(
animation: Listenable.merge([_nextPrayerName, _nextPrayerTime]),
builder: (context, _) {
final name = _nextPrayerName.value.isNotEmpty
? _nextPrayerName.value
: fallbackPrayerName;
final time = _nextPrayerTime.value.isNotEmpty
? _nextPrayerTime.value
: fallbackTime;
return Text(
'$name$time',
style: const TextStyle(
@@ -291,13 +567,24 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
),
const SizedBox(height: 4),
// City name
Row(
mainAxisSize: MainAxisSize.min,
children: [
AppIcon(
glyph: AppIcons.location,
size: 14,
color: AppColors.onPrimary.withValues(alpha: 0.7),
),
const SizedBox(width: 6),
Text(
'📍 ${schedule.cityName}',
schedule.cityName,
style: TextStyle(
fontSize: 13,
color: AppColors.onPrimary.withValues(alpha: 0.7),
),
),
],
),
const SizedBox(height: 16),
Row(
children: [
@@ -313,12 +600,16 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
child: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(LucideIcons.compass, size: 18, color: Colors.white),
AppIcon(
glyph: AppIcons.qibla,
size: 18,
color: AppColors.primary,
),
SizedBox(width: 8),
Text(
'Arah Kiblat',
style: TextStyle(
color: Colors.white,
color: AppColors.primary,
fontWeight: FontWeight.w600,
fontSize: 14,
),
@@ -329,19 +620,31 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
),
),
const SizedBox(width: 12),
Container(
GestureDetector(
onTap: _toggleAdhanFromHero,
child: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.2),
color: isAdhanEnabled
? Colors.white.withValues(alpha: 0.2)
: Colors.white.withValues(alpha: 0.12),
shape: BoxShape.circle,
border: Border.all(
color: Colors.white.withValues(
alpha: isAdhanEnabled ? 0.0 : 0.35,
),
child: const Icon(
LucideIcons.volume2,
),
),
child: Icon(
isAdhanEnabled
? LucideIcons.volume2
: LucideIcons.volumeX,
color: AppColors.onPrimary,
size: 22,
),
),
),
],
),
],
@@ -372,25 +675,31 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
Expanded(
child: Text(
prayerTimesAsync.value?.isTomorrow == true
? 'Jadwal Sholat Besok'
: 'Jadwal Sholat Hari Ini',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(fontWeight: FontWeight.w700)),
?.copyWith(fontWeight: FontWeight.w700),
),
),
const SizedBox(width: 12),
Container(
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: AppColors.primary.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(50),
),
child: Text(
prayerTimesAsync.value?.isTomorrow == true ? 'BESOK' : 'HARI INI',
prayerTimesAsync.value?.isTomorrow == true
? 'BESOK'
: 'HARI INI',
style: const TextStyle(
color: AppColors.primary,
fontSize: 10,
@@ -407,10 +716,17 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
child: prayerTimesAsync.when(
data: (schedule) {
if (schedule == null) return const SizedBox();
final prayers = schedule.prayerList.where(
final activePrayerName =
_resolveNextPrayer(schedule, DateTime.now())?.name;
final activePrayerKey = activePrayerName == null
? null
: '${schedule.date}:$activePrayerName';
final prayers = schedule.prayerList
.where(
(p) => ['Subuh', 'Dzuhur', 'Ashar', 'Maghrib', 'Isya']
.contains(p.name),
).toList();
)
.toList();
return ListView.separated(
controller: _prayerScrollController,
scrollDirection: Axis.horizontal,
@@ -419,14 +735,20 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
itemBuilder: (context, i) {
final p = prayers[i];
final icon = _prayerIcon(p.name);
final isActive = p.name == activePrayerName;
// Auto-scroll to active prayer on first build
if (p.isActive && i > 0 && !_hasAutoScrolled) {
if (isActive &&
i > 0 &&
(!_hasAutoScrolled ||
_lastAutoScrollPrayerKey != activePrayerKey)) {
_hasAutoScrolled = true;
_lastAutoScrollPrayerKey = activePrayerKey;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_prayerScrollController.hasClients) {
final targetOffset = i * 124.0; // 112 width + 12 gap
_prayerScrollController.animateTo(
targetOffset.clamp(0, _prayerScrollController.position.maxScrollExtent),
targetOffset.clamp(0,
_prayerScrollController.position.maxScrollExtent),
duration: const Duration(milliseconds: 400),
curve: Curves.easeOut,
);
@@ -437,15 +759,13 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
prayerName: p.name,
time: p.time,
icon: icon,
isActive: p.isActive,
isActive: isActive,
);
},
);
},
loading: () =>
const Center(child: CircularProgressIndicator()),
error: (_, __) =>
const Center(child: Text('Gagal memuat jadwal')),
loading: () => const Center(child: CircularProgressIndicator()),
error: (_, __) => const Center(child: Text('Gagal memuat jadwal')),
),
),
],
@@ -561,8 +881,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
],
),
const SizedBox(height: 16),
_checklistPreviewItem(
context, isDark, 'Sholat Fardhu', '$fardhuCompleted dari 5 selesai', fardhuCompleted == 5),
_checklistPreviewItem(context, isDark, 'Sholat Fardhu',
'$fardhuCompleted dari 5 selesai', fardhuCompleted == 5),
const SizedBox(height: 8),
_checklistPreviewItem(
context, isDark, 'Amalan Selesai', amalanText, points > 50),
@@ -637,7 +957,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
final now = DateTime.now();
// Reverse so today is on the far right (index 6)
final last7Days = List.generate(7, (i) => now.subtract(Duration(days: 6 - i)));
final last7Days =
List.generate(7, (i) => now.subtract(Duration(days: 6 - i)));
final daysLabels = ['Sen', 'Sel', 'Rab', 'Kam', 'Jum', 'Sab', 'Min'];
final weekPoints = <int>[];
@@ -675,6 +996,11 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
children: List.generate(7, (i) {
final val = weekPoints[i];
final ratio = (val / maxPts).clamp(0.1, 1.0);
final labelColor = i == 6
? AppColors.primary
: (isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight);
return Expanded(
child: Padding(
@@ -683,31 +1009,47 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
height: 80,
height: 96,
child: Align(
alignment: Alignment.bottomCenter,
child: Container(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'$val',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
color: labelColor,
),
),
const SizedBox(height: 6),
Container(
width: 24,
height: 80 * ratio,
decoration: BoxDecoration(
color: val > 0
? AppColors.primary.withValues(
alpha: 0.2 + ratio * 0.8)
: AppColors.primary.withValues(alpha: 0.1),
alpha: 0.2 + ratio * 0.8,
)
: AppColors.primary
.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
),
],
),
),
),
const SizedBox(height: 8),
Text(
daysLabels[last7Days[i].weekday - 1], // Correct localized day
daysLabels[
last7Days[i].weekday - 1], // Correct localized day
style: TextStyle(
fontSize: 10,
fontWeight: i == 6 ? FontWeight.w800 : FontWeight.w600,
color: i == 6
? AppColors.primary
: (isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight),
fontWeight:
i == 6 ? FontWeight.w800 : FontWeight.w600,
color: labelColor,
),
),
],
@@ -722,6 +1064,78 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
}
Widget _buildQuickActions(BuildContext context, bool isDark) {
final isSimpleMode = _isSimpleMode;
final cards = <Widget>[
if (!isSimpleMode)
ToolCard(
icon: AppIcons.quran,
title: "Al-Qur'an\nTerjemahan",
color: const Color(0xFF00B894),
isDark: isDark,
onTap: () => context.push('/tools/quran'),
),
ToolCard(
icon: AppIcons.murattal,
title: "Qur'an\nMurattal",
color: const Color(0xFF7B61FF),
isDark: isDark,
onTap: () {
if (isSimpleMode) {
context.go('/quran/1/murattal');
} else {
context.push('/tools/quran/1/murattal');
}
},
),
if (!isSimpleMode)
ToolCard(
icon: AppIcons.dzikir,
title: 'Dzikir\nHarian',
color: AppColors.primary,
isDark: isDark,
onTap: () => context.push('/tools/dzikir'),
),
ToolCard(
icon: AppIcons.doa,
title: 'Kumpulan\nDoa',
color: const Color(0xFFE17055),
isDark: isDark,
onTap: () {
if (isSimpleMode) {
context.push('/doa');
} else {
context.push('/tools/doa');
}
},
),
ToolCard(
icon: AppIcons.hadits,
title: "Hadits\nArba'in",
color: const Color(0xFF6C5CE7),
isDark: isDark,
onTap: () {
if (isSimpleMode) {
context.push('/hadits');
} else {
context.push('/tools/hadits');
}
},
),
ToolCard(
icon: AppIcons.quranEnrichment,
title: "Pendalaman\nAl-Qur'an",
color: const Color(0xFF00CEC9),
isDark: isDark,
onTap: () {
if (isSimpleMode) {
context.push('/quran/enrichment');
} else {
context.push('/tools/quran/enrichment');
}
},
),
];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -735,169 +1149,36 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
),
),
const SizedBox(height: 12),
Row(
_buildQuickActionsGrid(cards),
],
);
}
Widget _buildQuickActionsGrid(List<Widget> cards) {
const spacing = 12.0;
return LayoutBuilder(
builder: (context, constraints) {
final cardWidth = (constraints.maxWidth - spacing) / 2;
return Wrap(
spacing: spacing,
runSpacing: spacing,
children: [
Expanded(
child: ToolCard(
icon: LucideIcons.bookOpen,
title: 'Al-Quran\nTerjemahan',
color: const Color(0xFF00B894),
isDark: isDark,
onTap: () {
final isSimple = Hive.box<AppSettings>(HiveBoxes.settings).get('default')?.simpleMode ?? false;
if (isSimple) {
context.go('/quran');
} else {
context.push('/tools/quran');
}
},
),
),
const SizedBox(width: 12),
Expanded(
child: ToolCard(
icon: LucideIcons.headphones,
title: 'Quran\nMurattal',
color: const Color(0xFF7B61FF),
isDark: isDark,
onTap: () {
final isSimple = Hive.box<AppSettings>(HiveBoxes.settings).get('default')?.simpleMode ?? false;
if (isSimple) {
context.go('/quran/1/murattal');
} else {
context.push('/tools/quran/1/murattal');
}
},
),
),
for (final card in cards) SizedBox(width: cardWidth, child: card),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: ToolCard(
icon: LucideIcons.compass,
title: 'Arah\nKiblat',
color: const Color(0xFF0984E3),
isDark: isDark,
onTap: () {
final isSimple = Hive.box<AppSettings>(HiveBoxes.settings).get('default')?.simpleMode ?? false;
if (isSimple) {
context.push('/qibla');
} else {
context.push('/tools/qibla');
}
);
},
),
),
const SizedBox(width: 12),
Expanded(
child: ToolCard(
icon: LucideIcons.sparkles,
title: 'Tasbih\nDigital',
color: AppColors.primary,
isDark: isDark,
onTap: () {
final isSimple = Hive.box<AppSettings>(HiveBoxes.settings).get('default')?.simpleMode ?? false;
if (isSimple) {
context.go('/dzikir');
} else {
context.push('/tools/dzikir');
}
},
),
),
],
),
],
);
}
Widget _buildAyatHariIni(BuildContext context, bool isDark) {
return FutureBuilder<Map<String, dynamic>?>(
future: EQuranService.instance.getDailyAyat(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: isDark ? AppColors.primary.withValues(alpha: 0.08) : const Color(0xFFF5F9F0),
borderRadius: BorderRadius.circular(16),
),
child: const Center(child: CircularProgressIndicator()),
);
}
if (!snapshot.hasData || snapshot.data == null) {
return const SizedBox.shrink();
}
final data = snapshot.data!;
return Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: isDark ? AppColors.primary.withValues(alpha: 0.08) : const Color(0xFFF5F9F0),
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'AYAT HARI INI',
style: TextStyle(
return const AyatTodayCard(
headerText: 'AYAT HARI INI',
headerStyle: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
letterSpacing: 1.5,
color: AppColors.sage,
),
),
Icon(LucideIcons.quote,
size: 20,
color: isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight),
],
),
const SizedBox(height: 16),
Align(
alignment: Alignment.centerRight,
child: Text(
data['teksArab'] ?? '',
style: const TextStyle(
fontFamily: 'Amiri',
fontSize: 24,
height: 1.8,
),
textAlign: TextAlign.right,
),
),
const SizedBox(height: 16),
Text(
'"${data['teksIndonesia'] ?? ''}"',
style: TextStyle(
fontSize: 14,
fontStyle: FontStyle.italic,
height: 1.5,
color: isDark ? Colors.white : Colors.black87,
),
),
const SizedBox(height: 12),
Text(
'QS. ${data['surahName']}: ${data['nomorAyat']}',
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.primary,
),
),
],
),
);
},
);
}
}

View File

@@ -0,0 +1,277 @@
import 'package:flutter/material.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../../../app/theme/app_colors.dart';
import '../../../core/widgets/arabic_text.dart';
import '../../../data/local/hive_boxes.dart';
import '../../../data/local/models/app_settings.dart';
import '../../../data/services/muslim_api_service.dart';
class DoaScreen extends StatefulWidget {
final bool isSimpleModeTab;
const DoaScreen({super.key, this.isSimpleModeTab = false});
@override
State<DoaScreen> createState() => _DoaScreenState();
}
class _DoaScreenState extends State<DoaScreen> {
final TextEditingController _searchController = TextEditingController();
List<Map<String, dynamic>> _allDoa = [];
List<Map<String, dynamic>> _filteredDoa = [];
bool _loading = true;
String? _error;
@override
void initState() {
super.initState();
_loadDoa();
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
Future<void> _loadDoa() async {
setState(() {
_loading = true;
_error = null;
});
try {
final data = await MuslimApiService.instance.getDoaList(strict: true);
if (!mounted) return;
setState(() {
_allDoa = data;
_filteredDoa = data;
_loading = false;
});
} catch (_) {
if (!mounted) return;
setState(() {
_allDoa = [];
_filteredDoa = [];
_loading = false;
_error = 'Gagal memuat doa dari server';
});
}
}
void _onSearchChanged(String value) {
final q = value.trim().toLowerCase();
if (q.isEmpty) {
setState(() => _filteredDoa = _allDoa);
return;
}
setState(() {
_filteredDoa = _allDoa.where((item) {
final title = item['judul']?.toString().toLowerCase() ?? '';
final indo = item['indo']?.toString().toLowerCase() ?? '';
return title.contains(q) || indo.contains(q);
}).toList();
});
}
void _showArabicFontSettings() {
final settingsBox = Hive.box<AppSettings>(HiveBoxes.settings);
final settings = settingsBox.get('default') ?? AppSettings();
if (!settings.isInBox) {
settingsBox.put('default', settings);
}
double arabicFontSize = settings.arabicFontSize;
showModalBottomSheet(
context: context,
isScrollControlled: true,
useSafeArea: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (ctx) => StatefulBuilder(
builder: (context, setModalState) {
final keyboardInset = MediaQuery.of(context).viewInsets.bottom;
return Padding(
padding: EdgeInsets.fromLTRB(16, 20, 16, 20 + keyboardInset),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Pengaturan Tampilan',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
const Text('Ukuran Font Arab'),
Slider(
value: arabicFontSize,
min: 16,
max: 40,
divisions: 12,
label: '${arabicFontSize.round()}pt',
activeColor: AppColors.primary,
onChanged: (value) {
setModalState(() => arabicFontSize = value);
settings.arabicFontSize = value;
if (settings.isInBox) {
settings.save();
} else {
settingsBox.put('default', settings);
}
},
),
const SizedBox(height: 8),
],
),
);
},
),
);
}
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
appBar: AppBar(
automaticallyImplyLeading: !widget.isSimpleModeTab,
title: const Text('Kumpulan Doa'),
actionsPadding: const EdgeInsets.only(right: 8),
actions: [
IconButton(
onPressed: _loadDoa,
icon: const Icon(LucideIcons.refreshCw),
tooltip: 'Muat ulang',
),
IconButton(
onPressed: _showArabicFontSettings,
icon: const Icon(LucideIcons.settings2),
tooltip: 'Pengaturan tampilan',
),
],
),
body: SafeArea(
top: false,
bottom: !widget.isSimpleModeTab,
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
child: TextField(
controller: _searchController,
onChanged: _onSearchChanged,
decoration: InputDecoration(
hintText: 'Cari judul atau isi doa...',
prefixIcon: const Icon(LucideIcons.search),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
),
Expanded(
child: _loading
? const Center(child: CircularProgressIndicator())
: _error != null
? Center(
child: Text(
_error!,
style: TextStyle(
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
),
)
: _filteredDoa.isEmpty
? Center(
child: Text(
'Doa tidak ditemukan',
style: TextStyle(
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
),
)
: ListView.builder(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
itemCount: _filteredDoa.length,
itemBuilder: (context, index) {
final item = _filteredDoa[index];
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: isDark
? AppColors.surfaceDark
: AppColors.surfaceLight,
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: isDark
? AppColors.primary
.withValues(alpha: 0.1)
: AppColors.cream,
),
),
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
item['judul']?.toString() ?? '-',
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: AppColors.primary,
),
),
const SizedBox(height: 10),
Align(
alignment: Alignment.centerRight,
child: ArabicText(
item['arab']?.toString() ?? '',
textAlign: TextAlign.right,
baseFontSize: 24,
fontWeight: FontWeight.w400,
height: 1.8,
),
),
const SizedBox(height: 8),
Text(
item['indo']?.toString() ?? '',
style: TextStyle(
height: 1.5,
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
),
if ((item['source']
?.toString()
.isNotEmpty ??
false)) ...[
const SizedBox(height: 10),
Text(
'Sumber: ${item['source']}',
style: const TextStyle(
fontSize: 12,
color: AppColors.primary,
fontWeight: FontWeight.w600,
),
),
],
],
),
);
},
),
),
],
),
),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,293 @@
import 'package:flutter/material.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../../../app/theme/app_colors.dart';
import '../../../core/widgets/arabic_text.dart';
import '../../../data/local/hive_boxes.dart';
import '../../../data/local/models/app_settings.dart';
import '../../../data/services/muslim_api_service.dart';
class HaditsScreen extends StatefulWidget {
final bool isSimpleModeTab;
const HaditsScreen({super.key, this.isSimpleModeTab = false});
@override
State<HaditsScreen> createState() => _HaditsScreenState();
}
class _HaditsScreenState extends State<HaditsScreen> {
final TextEditingController _searchController = TextEditingController();
List<Map<String, dynamic>> _allHadits = [];
List<Map<String, dynamic>> _filteredHadits = [];
bool _loading = true;
String? _error;
@override
void initState() {
super.initState();
_loadHadits();
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
Future<void> _loadHadits() async {
setState(() {
_loading = true;
_error = null;
});
try {
final data = await MuslimApiService.instance.getHaditsList(strict: true);
if (!mounted) return;
data.sort((a, b) {
final aa = (a['no'] as num?)?.toInt() ?? 0;
final bb = (b['no'] as num?)?.toInt() ?? 0;
return aa.compareTo(bb);
});
setState(() {
_allHadits = data;
_filteredHadits = data;
_loading = false;
});
} catch (_) {
if (!mounted) return;
setState(() {
_allHadits = [];
_filteredHadits = [];
_loading = false;
_error = 'Gagal memuat hadits dari server';
});
}
}
void _onSearchChanged(String value) {
final q = value.trim().toLowerCase();
if (q.isEmpty) {
setState(() => _filteredHadits = _allHadits);
return;
}
setState(() {
_filteredHadits = _allHadits.where((item) {
final title = item['judul']?.toString().toLowerCase() ?? '';
final indo = item['indo']?.toString().toLowerCase() ?? '';
return title.contains(q) || indo.contains(q);
}).toList();
});
}
void _showArabicFontSettings() {
final settingsBox = Hive.box<AppSettings>(HiveBoxes.settings);
final settings = settingsBox.get('default') ?? AppSettings();
if (!settings.isInBox) {
settingsBox.put('default', settings);
}
double arabicFontSize = settings.arabicFontSize;
showModalBottomSheet(
context: context,
isScrollControlled: true,
useSafeArea: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (ctx) => StatefulBuilder(
builder: (context, setModalState) {
final keyboardInset = MediaQuery.of(context).viewInsets.bottom;
return Padding(
padding: EdgeInsets.fromLTRB(16, 20, 16, 20 + keyboardInset),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Pengaturan Tampilan',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
const Text('Ukuran Font Arab'),
Slider(
value: arabicFontSize,
min: 16,
max: 40,
divisions: 12,
label: '${arabicFontSize.round()}pt',
activeColor: AppColors.primary,
onChanged: (value) {
setModalState(() => arabicFontSize = value);
settings.arabicFontSize = value;
if (settings.isInBox) {
settings.save();
} else {
settingsBox.put('default', settings);
}
},
),
const SizedBox(height: 8),
],
),
);
},
),
);
}
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
appBar: AppBar(
automaticallyImplyLeading: !widget.isSimpleModeTab,
title: const Text("Hadits Arba'in"),
actionsPadding: const EdgeInsets.only(right: 8),
actions: [
IconButton(
onPressed: _loadHadits,
icon: const Icon(LucideIcons.refreshCw),
tooltip: 'Muat ulang',
),
IconButton(
onPressed: _showArabicFontSettings,
icon: const Icon(LucideIcons.settings2),
tooltip: 'Pengaturan tampilan',
),
],
),
body: SafeArea(
top: false,
bottom: !widget.isSimpleModeTab,
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
child: TextField(
controller: _searchController,
onChanged: _onSearchChanged,
decoration: InputDecoration(
hintText: 'Cari judul atau isi hadits...',
prefixIcon: const Icon(LucideIcons.search),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
),
Expanded(
child: _loading
? const Center(child: CircularProgressIndicator())
: _error != null
? Center(
child: Text(
_error!,
style: TextStyle(
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
),
)
: _filteredHadits.isEmpty
? Center(
child: Text(
'Hadits tidak ditemukan',
style: TextStyle(
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
),
)
: ListView.builder(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
itemCount: _filteredHadits.length,
itemBuilder: (context, index) {
final item = _filteredHadits[index];
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: isDark
? AppColors.surfaceDark
: AppColors.surfaceLight,
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: isDark
? AppColors.primary
.withValues(alpha: 0.1)
: AppColors.cream,
),
),
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 34,
height: 34,
alignment: Alignment.center,
decoration: BoxDecoration(
color: AppColors.primary
.withValues(alpha: 0.12),
borderRadius:
BorderRadius.circular(10),
),
child: Text(
'${item['no'] ?? '-'}',
style: const TextStyle(
fontWeight: FontWeight.w700,
color: AppColors.primary,
),
),
),
const SizedBox(width: 10),
Expanded(
child: Text(
item['judul']?.toString() ?? '-',
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: AppColors.primary,
),
),
),
],
),
const SizedBox(height: 10),
Align(
alignment: Alignment.centerRight,
child: ArabicText(
item['arab']?.toString() ?? '',
textAlign: TextAlign.right,
baseFontSize: 24,
fontWeight: FontWeight.w400,
height: 1.8,
),
),
const SizedBox(height: 8),
Text(
item['indo']?.toString() ?? '',
style: TextStyle(
height: 1.5,
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
),
],
),
);
},
),
),
],
),
),
);
}
}

View File

@@ -6,6 +6,7 @@ import 'package:go_router/go_router.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:intl/intl.dart';
import '../../../app/theme/app_colors.dart';
import '../../../core/widgets/notification_bell_button.dart';
import '../../../data/local/hive_boxes.dart';
import '../../../data/local/models/app_settings.dart';
import '../../../data/services/prayer_service.dart';
@@ -56,8 +57,7 @@ class _ImsakiyahScreenState extends ConsumerState<ImsakiyahScreen> {
List<_DayRow> _createRows(Map<String, Map<String, String>>? apiData) {
final selected = _months[_selectedMonthIndex];
final daysInMonth =
DateTime(selected.year, selected.month + 1, 0).day;
final daysInMonth = DateTime(selected.year, selected.month + 1, 0).day;
final rows = <_DayRow>[];
for (int d = 1; d <= daysInMonth; d++) {
@@ -102,7 +102,8 @@ class _ImsakiyahScreenState extends ConsumerState<ImsakiyahScreen> {
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setDialogState) => AlertDialog(
insetPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
insetPadding:
const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
title: const Text('Cari Kota/Kabupaten'),
content: SizedBox(
width: MediaQuery.of(context).size.width * 0.85,
@@ -135,12 +136,14 @@ class _ImsakiyahScreenState extends ConsumerState<ImsakiyahScreen> {
if (val.trim().length < 3) return;
if (debounce?.isActive ?? false) debounce!.cancel();
debounce = Timer(const Duration(milliseconds: 500), () async {
debounce =
Timer(const Duration(milliseconds: 500), () async {
if (!mounted) return;
setDialogState(() => isSearching = true);
try {
final res = await MyQuranSholatService.instance.searchCity(val.trim());
final res = await MyQuranSholatService.instance
.searchCity(val.trim());
if (mounted) {
setDialogState(() {
results = res;
@@ -175,7 +178,8 @@ class _ImsakiyahScreenState extends ConsumerState<ImsakiyahScreen> {
if (isSearching)
const Center(child: CircularProgressIndicator())
else if (results.isEmpty)
const Text('Tidak ada hasil', style: TextStyle(color: Colors.grey))
const Text('Tidak ada hasil',
style: TextStyle(color: Colors.grey))
else
SizedBox(
height: 200,
@@ -224,9 +228,11 @@ class _ImsakiyahScreenState extends ConsumerState<ImsakiyahScreen> {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
final today = DateTime.now();
const tableBottomSpacing = 28.0;
final selectedMonth = _months[_selectedMonthIndex];
final monthArg = '${selectedMonth.year}-${selectedMonth.month.toString().padLeft(2, '0')}';
final monthArg =
'${selectedMonth.year}-${selectedMonth.month.toString().padLeft(2, '0')}';
final cityNameAsync = ref.watch(cityNameProvider);
final monthlyDataAsync = ref.watch(monthlyScheduleProvider(monthArg));
@@ -235,10 +241,7 @@ class _ImsakiyahScreenState extends ConsumerState<ImsakiyahScreen> {
title: const Text('Kalender Sholat'),
centerTitle: false,
actions: [
IconButton(
onPressed: () {},
icon: const Icon(LucideIcons.bell),
),
const NotificationBellButton(),
IconButton(
onPressed: () => context.push('/settings'),
icon: const Icon(LucideIcons.settings),
@@ -266,7 +269,9 @@ class _ImsakiyahScreenState extends ConsumerState<ImsakiyahScreen> {
decoration: BoxDecoration(
color: isSelected
? AppColors.primary
: (isDark ? AppColors.surfaceDark : AppColors.surfaceLight),
: (isDark
? AppColors.surfaceDark
: AppColors.surfaceLight),
borderRadius: BorderRadius.circular(50),
border: isSelected
? null
@@ -306,7 +311,8 @@ class _ImsakiyahScreenState extends ConsumerState<ImsakiyahScreen> {
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: isDark ? AppColors.surfaceDark : AppColors.surfaceLight,
color:
isDark ? AppColors.surfaceDark : AppColors.surfaceLight,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: isDark
@@ -378,7 +384,12 @@ class _ImsakiyahScreenState extends ConsumerState<ImsakiyahScreen> {
data: (apiData) {
final rows = _createRows(apiData);
return ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.fromLTRB(
16,
0,
16,
tableBottomSpacing,
),
itemCount: rows.length,
itemBuilder: (context, i) {
final row = rows[i];
@@ -453,7 +464,12 @@ class _ImsakiyahScreenState extends ConsumerState<ImsakiyahScreen> {
error: (_, __) {
final rows = _createRows(null); // fallback
return ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.fromLTRB(
16,
0,
16,
tableBottomSpacing,
),
itemCount: rows.length,
itemBuilder: (context, i) {
final row = rows[i];
@@ -536,7 +552,7 @@ class _ImsakiyahScreenState extends ConsumerState<ImsakiyahScreen> {
child: Center(
child: Text(
text,
style: TextStyle(
style: const TextStyle(
fontSize: 9,
fontWeight: FontWeight.w700,
letterSpacing: 1,

View File

@@ -5,11 +5,10 @@ import 'package:hive_flutter/hive_flutter.dart';
import 'package:intl/intl.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../../../app/theme/app_colors.dart';
import '../../../core/widgets/progress_bar.dart';
import '../../../core/widgets/notification_bell_button.dart';
import '../../../data/local/hive_boxes.dart';
import '../../../data/local/models/app_settings.dart';
import '../../../data/local/models/daily_worship_log.dart';
import '../../../data/local/models/checklist_item.dart';
class LaporanScreen extends ConsumerStatefulWidget {
const LaporanScreen({super.key});
@@ -80,34 +79,46 @@ class _LaporanScreenState extends ConsumerState<LaporanScreen>
// Fardhu
totalCounts['fardhu'] = (totalCounts['fardhu'] ?? 0) + 5;
int completedFardhu = log.shalatLogs.values.where((l) => l.completed).length;
completionCounts['fardhu'] = (completionCounts['fardhu'] ?? 0) + completedFardhu;
int completedFardhu =
log.shalatLogs.values.where((l) => l.completed).length;
completionCounts['fardhu'] =
(completionCounts['fardhu'] ?? 0) + completedFardhu;
// Rawatib
int rawatibTotal = 0;
int rawatibCompleted = 0;
for (var sLog in log.shalatLogs.values) {
if (sLog.qabliyah != null) { rawatibTotal++; if (sLog.qabliyah!) rawatibCompleted++; }
if (sLog.badiyah != null) { rawatibTotal++; if (sLog.badiyah!) rawatibCompleted++; }
if (sLog.qabliyah != null) {
rawatibTotal++;
if (sLog.qabliyah!) rawatibCompleted++;
}
if (sLog.badiyah != null) {
rawatibTotal++;
if (sLog.badiyah!) rawatibCompleted++;
}
}
if (rawatibTotal > 0) {
totalCounts['rawatib'] = (totalCounts['rawatib'] ?? 0) + rawatibTotal;
completionCounts['rawatib'] = (completionCounts['rawatib'] ?? 0) + rawatibCompleted;
completionCounts['rawatib'] =
(completionCounts['rawatib'] ?? 0) + rawatibCompleted;
}
// Tilawah
if (log.tilawahLog != null) {
totalCounts['tilawah'] = (totalCounts['tilawah'] ?? 0) + 1;
if (log.tilawahLog!.isCompleted) {
completionCounts['tilawah'] = (completionCounts['tilawah'] ?? 0) + 1;
completionCounts['tilawah'] =
(completionCounts['tilawah'] ?? 0) + 1;
}
}
// Dzikir
if (log.dzikirLog != null) {
totalCounts['dzikir'] = (totalCounts['dzikir'] ?? 0) + 2;
int dCompleted = (log.dzikirLog!.pagi ? 1 : 0) + (log.dzikirLog!.petang ? 1 : 0);
completionCounts['dzikir'] = (completionCounts['dzikir'] ?? 0) + dCompleted;
int dCompleted =
(log.dzikirLog!.pagi ? 1 : 0) + (log.dzikirLog!.petang ? 1 : 0);
completionCounts['dzikir'] =
(completionCounts['dzikir'] ?? 0) + dCompleted;
}
// Puasa
@@ -180,10 +191,7 @@ class _LaporanScreenState extends ConsumerState<LaporanScreen>
title: const Text('Riwayat Ibadah'),
centerTitle: false,
actions: [
IconButton(
onPressed: () {},
icon: const Icon(LucideIcons.bell),
),
const NotificationBellButton(),
IconButton(
onPressed: () => context.push('/settings'),
icon: const Icon(LucideIcons.settings),
@@ -204,10 +212,7 @@ class _LaporanScreenState extends ConsumerState<LaporanScreen>
title: const Text('Laporan Kualitas Ibadah'),
centerTitle: false,
actions: [
IconButton(
onPressed: () {},
icon: const Icon(LucideIcons.bell),
),
const NotificationBellButton(),
IconButton(
onPressed: () => context.push('/settings'),
icon: const Icon(LucideIcons.settings),
@@ -253,7 +258,8 @@ class _LaporanScreenState extends ConsumerState<LaporanScreen>
child: TabBarView(
controller: _tabController,
children: [
_buildWeeklyView(context, isDark, weekData, avgPercent, insights),
_buildWeeklyView(
context, isDark, weekData, avgPercent, insights),
_buildComingSoon(context, 'Bulanan'),
_buildComingSoon(context, 'Tahunan'),
],
@@ -332,10 +338,12 @@ class _LaporanScreenState extends ConsumerState<LaporanScreen>
const SizedBox(height: 20),
// ── Bar Chart ──
SizedBox(
height: 140,
child: Builder(
builder: (context) {
final maxPts = weekData.map((d) => d.value).fold<double>(0.0, (a, b) => a > b ? a : b).clamp(50.0, 300.0);
height: 162,
child: Builder(builder: (context) {
final maxPts = weekData
.map((d) => d.value)
.fold<double>(0.0, (a, b) => a > b ? a : b)
.clamp(50.0, 300.0);
return Row(
crossAxisAlignment: CrossAxisAlignment.end,
@@ -347,6 +355,19 @@ class _LaporanScreenState extends ConsumerState<LaporanScreen>
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Text(
'${d.value.round()}',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
color: d.isToday
? AppColors.primary
: (isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight),
),
),
const SizedBox(height: 8),
Flexible(
child: Container(
width: double.infinity,
@@ -354,8 +375,8 @@ class _LaporanScreenState extends ConsumerState<LaporanScreen>
decoration: BoxDecoration(
color: d.isToday
? AppColors.primary
: AppColors.primary
.withValues(alpha: 0.3 + ratio * 0.4),
: AppColors.primary.withValues(
alpha: 0.3 + ratio * 0.4),
borderRadius: BorderRadius.circular(6),
),
),
@@ -381,8 +402,7 @@ class _LaporanScreenState extends ConsumerState<LaporanScreen>
);
}).toList(),
);
}
),
}),
),
],
),
@@ -587,9 +607,11 @@ class _LaporanScreenState extends ConsumerState<LaporanScreen>
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(LucideIcons.history, size: 64, color: AppColors.sage.withValues(alpha: 0.5)),
Icon(LucideIcons.history,
size: 64, color: AppColors.sage.withValues(alpha: 0.5)),
const SizedBox(height: 16),
const Text('Belum ada riwayat ibadah', style: TextStyle(color: AppColors.sage)),
const Text('Belum ada riwayat ibadah',
style: TextStyle(color: AppColors.sage)),
],
),
);
@@ -605,7 +627,8 @@ class _LaporanScreenState extends ConsumerState<LaporanScreen>
// Build summary text
final List<String> finished = [];
int fardhuCount = log.shalatLogs.values.where((l) => l.completed).length;
int fardhuCount =
log.shalatLogs.values.where((l) => l.completed).length;
if (fardhuCount > 0) finished.add('$fardhuCount Fardhu');
if (log.tilawahLog?.isCompleted == true) finished.add('Tilawah');
if (log.dzikirLog != null) {
@@ -635,7 +658,8 @@ class _LaporanScreenState extends ConsumerState<LaporanScreen>
color: AppColors.primary.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(LucideIcons.checkCircle2, color: AppColors.primary),
child: const Icon(LucideIcons.checkCircle2,
color: AppColors.primary),
),
const SizedBox(width: 16),
Expanded(
@@ -643,7 +667,10 @@ class _LaporanScreenState extends ConsumerState<LaporanScreen>
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
isToday ? 'Hari Ini' : DateFormat('EEEE, d MMM yyyy').format(DateTime.parse(log.date)),
isToday
? 'Hari Ini'
: DateFormat('EEEE, d MMM yyyy')
.format(DateTime.parse(log.date)),
style: const TextStyle(
fontWeight: FontWeight.w700,
fontSize: 15,
@@ -651,10 +678,14 @@ class _LaporanScreenState extends ConsumerState<LaporanScreen>
),
const SizedBox(height: 4),
Text(
finished.isNotEmpty ? finished.join('') : 'Belum ada aktivitas',
finished.isNotEmpty
? finished.join('')
: 'Belum ada aktivitas',
style: TextStyle(
fontSize: 13,
color: isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight,
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
height: 1.4,
),
),

View File

@@ -0,0 +1,879 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import '../../../app/icons/app_icons.dart';
import '../../../app/theme/app_colors.dart';
import '../../../data/services/notification_analytics_service.dart';
import '../../../data/services/notification_inbox_service.dart';
import '../../../data/services/notification_service.dart';
class NotificationCenterScreen extends StatefulWidget {
const NotificationCenterScreen({super.key});
@override
State<NotificationCenterScreen> createState() =>
_NotificationCenterScreenState();
}
class _NotificationCenterScreenState extends State<NotificationCenterScreen>
with TickerProviderStateMixin {
late final TabController _tabController;
late Future<List<NotificationPendingAlert>> _alarmsFuture;
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
unawaited(NotificationInboxService.instance.removeByType('prayer'));
_alarmsFuture = NotificationService.instance.pendingAlerts();
NotificationAnalyticsService.instance.track(
'notif_inbox_opened',
dimensions: const <String, dynamic>{'screen': 'notification_center'},
);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
Future<void> _refreshAlarms() async {
setState(() {
_alarmsFuture = NotificationService.instance.pendingAlerts();
});
await _alarmsFuture;
}
Future<void> _markAllRead() async {
await NotificationInboxService.instance.markAllRead();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Semua pesan sudah ditandai terbaca.')),
);
}
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final inboxListenable = NotificationInboxService.instance.listenable();
return Scaffold(
appBar: AppBar(
leading: IconButton(
onPressed: () => context.pop(),
icon: const AppIcon(glyph: AppIcons.backArrow),
),
title: const Text('Pemberitahuan'),
centerTitle: false,
actions: [
ListenableBuilder(
listenable: _tabController.animation!,
builder: (context, _) {
final tabIndex = _tabController.index;
if (tabIndex == 0) {
return IconButton(
onPressed: _refreshAlarms,
icon: Icon(
Icons.refresh_rounded,
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
);
}
return ValueListenableBuilder(
valueListenable: inboxListenable,
builder: (context, _, __) {
final unread =
NotificationInboxService.instance.unreadCount();
if (unread <= 0) return const SizedBox.shrink();
return TextButton(
onPressed: _markAllRead,
child: const Text('Tandai semua'),
);
},
);
},
),
const SizedBox(width: 6),
],
bottom: TabBar(
controller: _tabController,
indicatorColor: AppColors.primary,
labelColor: AppColors.primary,
unselectedLabelColor: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
tabs: [
FutureBuilder<List<NotificationPendingAlert>>(
future: _alarmsFuture,
builder: (context, snapshot) {
final count = snapshot.data?.length ?? 0;
return Tab(text: count > 0 ? 'Alarm ($count)' : 'Alarm');
},
),
ValueListenableBuilder(
valueListenable: inboxListenable,
builder: (context, _, __) {
final unread = NotificationInboxService.instance.unreadCount();
return Tab(text: unread > 0 ? 'Pesan ($unread)' : 'Pesan');
},
),
],
),
),
body: SafeArea(
top: false,
child: TabBarView(
controller: _tabController,
children: [
_AlarmTab(future: _alarmsFuture, onRefresh: _refreshAlarms),
_InboxTab(),
],
),
),
);
}
}
class _AlarmTab extends StatefulWidget {
const _AlarmTab({
required this.future,
required this.onRefresh,
});
final Future<List<NotificationPendingAlert>> future;
final Future<void> Function() onRefresh;
@override
State<_AlarmTab> createState() => _AlarmTabState();
}
class _AlarmTabState extends State<_AlarmTab> {
String _alarmFilter = 'upcoming';
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return FutureBuilder<List<NotificationPendingAlert>>(
future: widget.future,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
final alarms = snapshot.data ?? const <NotificationPendingAlert>[];
final now = DateTime.now();
final upcoming = alarms
.where((alarm) =>
alarm.scheduledAt == null || !alarm.scheduledAt!.isBefore(now))
.toList();
final passed = alarms
.where((alarm) =>
alarm.scheduledAt != null && alarm.scheduledAt!.isBefore(now))
.toList();
final visible = _alarmFilter == 'past' ? passed : upcoming;
if (alarms.isEmpty) {
return RefreshIndicator(
onRefresh: widget.onRefresh,
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
children: [
_buildAlarmFilters(
isDark: isDark,
upcomingCount: 0,
passedCount: 0,
),
const SizedBox(height: 20),
AppIcon(
glyph: AppIcons.notification,
size: 40,
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
const SizedBox(height: 12),
Text(
'Belum ada alarm aktif',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
Text(
'Alarm adzan dan iqamah akan muncul di sini saat sudah dijadwalkan.',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
),
],
),
);
}
return RefreshIndicator(
onRefresh: widget.onRefresh,
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
children: [
_buildAlarmFilters(
isDark: isDark,
upcomingCount: upcoming.length,
passedCount: passed.length,
),
const SizedBox(height: 12),
if (visible.isEmpty)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 24,
),
decoration: BoxDecoration(
color: isDark
? AppColors.surfaceDarkElevated
: AppColors.surfaceLight,
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: isDark
? AppColors.primary.withValues(alpha: 0.14)
: AppColors.cream,
),
),
child: Text(
_alarmFilter == 'upcoming'
? 'Tidak ada alarm akan datang.'
: 'Belum ada alarm sudah lewat.',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
),
),
for (final alarm in visible) ...[
_buildAlarmItem(context, isDark: isDark, alarm: alarm),
const SizedBox(height: 10),
],
],
),
);
},
);
}
Widget _buildAlarmItem(
BuildContext context, {
required bool isDark,
required NotificationPendingAlert alarm,
}) {
final chipColor = _chipColor(alarm.type);
final when = _formatAlarmTime(alarm.scheduledAt);
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: isDark ? AppColors.surfaceDarkElevated : AppColors.surfaceLight,
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: isDark
? AppColors.primary.withValues(alpha: 0.16)
: AppColors.cream,
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: chipColor.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(12),
),
child: Center(
child: AppIcon(
glyph: AppIcons.notification,
size: 18,
color: chipColor,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
alarm.title,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
if (alarm.body.isNotEmpty)
Text(
alarm.body,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
),
const SizedBox(height: 8),
Row(
children: [
_TypeBadge(
label: _alarmLabel(alarm.type),
color: chipColor,
),
const SizedBox(width: 8),
Text(
when,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
),
],
),
],
),
),
],
),
);
}
Widget _buildAlarmFilters({
required bool isDark,
required int upcomingCount,
required int passedCount,
}) {
return Wrap(
spacing: 8,
runSpacing: 8,
children: [
_FilterChip(
label: upcomingCount > 0
? 'Akan Datang ($upcomingCount)'
: 'Akan Datang',
selected: _alarmFilter == 'upcoming',
isDark: isDark,
onTap: () => setState(() => _alarmFilter = 'upcoming'),
),
_FilterChip(
label: passedCount > 0 ? 'Sudah Lewat ($passedCount)' : 'Sudah Lewat',
selected: _alarmFilter == 'past',
isDark: isDark,
onTap: () => setState(() => _alarmFilter = 'past'),
),
],
);
}
Color _chipColor(String type) {
switch (type) {
case 'adhan':
return AppColors.primary;
case 'iqamah':
return const Color(0xFF7B61FF);
case 'checklist':
return const Color(0xFF2D98DA);
case 'system':
return const Color(0xFFE17055);
default:
return AppColors.sage;
}
}
String _alarmLabel(String type) {
switch (type) {
case 'adhan':
return 'Adzan';
case 'iqamah':
return 'Iqamah';
case 'checklist':
return 'Checklist';
case 'system':
return 'Sistem';
default:
return 'Alarm';
}
}
String _formatAlarmTime(DateTime? value) {
if (value == null) return 'Waktu tidak diketahui';
try {
return DateFormat('EEE, d MMM • HH:mm', 'id_ID').format(value);
} catch (_) {
return DateFormat('d/MM • HH:mm').format(value);
}
}
}
class _InboxTab extends StatefulWidget {
@override
State<_InboxTab> createState() => _InboxTabState();
}
class _InboxTabState extends State<_InboxTab> {
String _filter = 'all';
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final inbox = NotificationInboxService.instance;
return ValueListenableBuilder(
valueListenable: inbox.listenable(),
builder: (context, _, __) {
final items = inbox.allItems(filter: _filter);
if (items.isEmpty) {
return ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
children: [
_buildFilters(isDark),
const SizedBox(height: 20),
AppIcon(
glyph: AppIcons.notification,
size: 40,
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
const SizedBox(height: 12),
Text(
'Belum ada pesan',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
Text(
'Pesan sistem dan ringkasan ibadah akan muncul di sini.',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
),
],
);
}
return ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
children: [
_buildFilters(isDark),
const SizedBox(height: 12),
...items.map((item) {
final accent = _inboxAccent(item.type);
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Dismissible(
key: ValueKey(item.id),
background: _swipeBackground(
isDark: isDark,
icon: item.isRead ? Icons.mark_email_unread : Icons.done,
label: item.isRead ? 'Belum dibaca' : 'Tandai dibaca',
alignment: Alignment.centerLeft,
color: AppColors.primary,
),
secondaryBackground: _swipeBackground(
isDark: isDark,
icon: Icons.delete_outline,
label: 'Hapus',
alignment: Alignment.centerRight,
color: AppColors.errorLight,
),
confirmDismiss: (direction) async {
if (direction == DismissDirection.startToEnd) {
if (item.isRead) {
await inbox.markUnread(item.id);
} else {
await inbox.markRead(item.id);
}
return false;
}
await inbox.remove(item.id);
return true;
},
child: InkWell(
borderRadius: BorderRadius.circular(14),
onTap: () async {
if (!item.isRead) {
await inbox.markRead(item.id);
}
await NotificationAnalyticsService.instance.track(
'notif_inbox_opened',
dimensions: <String, dynamic>{
'event_type': item.type,
'deeplink': item.deeplink ?? '',
},
);
if (!context.mounted) return;
final deeplink = item.deeplink;
if (deeplink != null && deeplink.isNotEmpty) {
if (deeplink.startsWith('/')) {
context.go(deeplink);
} else {
context.push(deeplink);
}
}
},
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: isDark
? AppColors.surfaceDarkElevated
: AppColors.surfaceLight,
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: !item.isRead
? accent.withValues(alpha: isDark ? 0.4 : 0.32)
: (isDark
? AppColors.primary.withValues(alpha: 0.14)
: AppColors.cream),
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: accent.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(12),
),
child: Center(
child: AppIcon(
glyph: _inboxGlyph(item.type),
size: 18,
color: accent,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
item.title,
style: Theme.of(context)
.textTheme
.titleSmall
?.copyWith(
fontWeight: FontWeight.w700),
),
),
if (item.isPinned)
const Padding(
padding: EdgeInsets.only(right: 6),
child: Icon(
Icons.push_pin_rounded,
size: 15,
color: AppColors.navActiveGoldDeep,
),
),
if (!item.isRead)
Container(
width: 8,
height: 8,
decoration: const BoxDecoration(
color: AppColors.primary,
shape: BoxShape.circle,
),
),
],
),
const SizedBox(height: 6),
Text(
item.body,
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
),
const SizedBox(height: 8),
Row(
children: [
_TypeBadge(
label: _inboxLabel(item.type),
color: accent,
),
const SizedBox(width: 8),
Text(
_formatInboxTime(item.createdAt),
style: Theme.of(context)
.textTheme
.bodySmall
?.copyWith(
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
),
],
),
],
),
),
IconButton(
onPressed: () => inbox.togglePinned(item.id),
icon: Icon(
item.isPinned
? Icons.push_pin_rounded
: Icons.push_pin_outlined,
size: 18,
color: item.isPinned
? AppColors.navActiveGoldDeep
: (isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight),
),
),
],
),
),
),
),
);
}),
],
);
},
);
}
Widget _buildFilters(bool isDark) {
return Wrap(
spacing: 8,
runSpacing: 8,
children: [
_FilterChip(
label: 'Semua',
selected: _filter == 'all',
isDark: isDark,
onTap: () => setState(() => _filter = 'all'),
),
_FilterChip(
label: 'Belum Dibaca',
selected: _filter == 'unread',
isDark: isDark,
onTap: () => setState(() => _filter = 'unread'),
),
_FilterChip(
label: 'Sistem',
selected: _filter == 'system',
isDark: isDark,
onTap: () => setState(() => _filter = 'system'),
),
],
);
}
Widget _swipeBackground({
required bool isDark,
required IconData icon,
required String label,
required Alignment alignment,
required Color color,
}) {
return Container(
alignment: alignment,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: color.withValues(alpha: isDark ? 0.18 : 0.12),
borderRadius: BorderRadius.circular(14),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 18, color: color),
const SizedBox(width: 6),
Text(
label,
style: TextStyle(
color: color,
fontWeight: FontWeight.w700,
fontSize: 12,
),
),
],
),
);
}
AppIconGlyph _inboxGlyph(String type) {
switch (type) {
case 'system':
return AppIcons.settings;
case 'summary':
case 'streak_risk':
return AppIcons.laporan;
case 'prayer':
return AppIcons.notification;
case 'content':
return AppIcons.notification;
default:
return AppIcons.notification;
}
}
Color _inboxAccent(String type) {
switch (type) {
case 'system':
return const Color(0xFFE17055);
case 'summary':
return const Color(0xFF7B61FF);
case 'prayer':
return AppColors.primary;
case 'content':
return const Color(0xFF00CEC9);
default:
return AppColors.sage;
}
}
String _inboxLabel(String type) {
switch (type) {
case 'system':
return 'Sistem';
case 'summary':
return 'Ringkasan';
case 'streak_risk':
return 'Pengingat';
case 'prayer':
return 'Sholat';
case 'content':
return 'Konten';
default:
return 'Pesan';
}
}
String _formatInboxTime(DateTime value) {
final now = DateTime.now();
final isToday = now.year == value.year &&
now.month == value.month &&
now.day == value.day;
try {
if (isToday) {
return DateFormat('HH:mm', 'id_ID').format(value);
}
return DateFormat('d MMM • HH:mm', 'id_ID').format(value);
} catch (_) {
if (isToday) {
return DateFormat('HH:mm').format(value);
}
return DateFormat('d/MM • HH:mm').format(value);
}
}
}
class _FilterChip extends StatelessWidget {
const _FilterChip({
required this.label,
required this.selected,
required this.isDark,
required this.onTap,
});
final String label;
final bool selected;
final bool isDark;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(999),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: selected
? AppColors.primary.withValues(alpha: isDark ? 0.22 : 0.16)
: (isDark
? AppColors.surfaceDarkElevated
: AppColors.surfaceLightElevated),
borderRadius: BorderRadius.circular(999),
border: Border.all(
color: selected
? AppColors.primary
: (isDark
? AppColors.primary.withValues(alpha: 0.2)
: AppColors.cream),
),
),
child: Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: selected
? AppColors.primary
: (isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight),
),
),
),
);
}
}
class _TypeBadge extends StatelessWidget {
const _TypeBadge({
required this.label,
required this.color,
});
final String label;
final Color color;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(10),
),
child: Text(
label,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: color,
),
),
);
}
}

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