feat: Murattal player enhancements & prayer schedule auto-scroll

- Murattal: Spotify-style 5-button controls [Shuffle, Prev, Play, Next, Playlist]
- Murattal: Animated 7-bar equalizer visualization in player circle
- Murattal: Unsplash API background with frosted glass player overlay
- Murattal: Transparent AppBar with backdrop blur
- Murattal: Surah playlist bottom sheet with full 114 Surah list
- Murattal: Auto-play disabled on screen open, enabled on navigation
- Murattal: Shuffle mode for random Surah playback
- Murattal: Photographer attribution per Unsplash guidelines
- Dashboard: Auto-scroll prayer schedule to next active prayer
- Fix: setState lifecycle errors on Reading & Murattal screens
- Setup: flutter_dotenv, cached_network_image, url_launcher deps
This commit is contained in:
dwindown
2026-03-13 15:42:17 +07:00
commit faadc1865d
189 changed files with 23834 additions and 0 deletions

48
.gitignore vendored Normal file
View File

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

33
.metadata Normal file
View File

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

1
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1 @@
{}

884
PRD.md Normal file
View File

@@ -0,0 +1,884 @@
# Product Requirements Document
## Jamshalat Diary — Islamic Worship Companion App
**Version:** 1.0
**Date:** March 2026
**Platform:** Flutter (iOS + Android)
**Status:** Draft
---
## Table of Contents
1. [Overview](#1-overview)
2. [Target Users](#2-target-users)
3. [Design System](#3-design-system)
4. [App Architecture](#4-app-architecture)
5. [Navigation](#5-navigation)
6. [Feature Specifications](#6-feature-specifications)
- 6.1 Dashboard
- 6.2 Prayer Calendar (Imsakiyah)
- 6.3 Daily Checklist
- 6.4 Dzikir
- 6.5 Reports (Laporan)
- 6.6 Qibla Finder
- 6.7 Quran Reading
- 6.8 Quran Murattal
- 6.9 Settings
7. [Data Model](#7-data-model)
8. [Third-Party Dependencies](#8-third-party-dependencies)
9. [Non-Functional Requirements](#9-non-functional-requirements)
10. [Permissions](#10-permissions)
11. [Out of Scope (v1.0)](#11-out-of-scope-v10)
---
## 1. Overview
**Jamshalat Diary** is an offline-first Muslim daily worship companion app built in Flutter. It helps Muslim users track their daily prayers, worship habits, and spiritual growth with a clean, modern UI that supports both light and dark themes.
**Core value proposition:**
- Never miss a prayer — real-time prayer time countdowns with Adhan/Iqamah notifications
- Track daily worship completion — checklist for Sholat, Tilawah, Dzikir
- Build consistent habits — weekly/monthly progress reports
- All-in-one Islamic toolkit — Qibla compass, Quran reader, Murattal player, Dzikir counter
---
## 2. Target Users
**Primary:** Muslim adults (1845) who want to improve consistency in daily worship
**Secondary:** Parents tracking worship habits for the family
**User goals:**
- Know exact prayer times for their location, offline
- Get reminded for Adhan and Iqamah
- Log daily ibadah (worship) completion
- Read Quran and perform guided Dzikir
- Find Qibla direction while traveling
- Review worship quality over time
---
## 3. Design System
### 3.1 Color Tokens
| Token | Light Value | Dark Value | Usage |
|---|---|---|---|
| `primary` | `#70df20` | `#70df20` | Active states, CTAs, progress fills |
| `background` | `#f7f8f6` | `#182111` | App background |
| `surface` | `#ffffff` | `#1e2a14` | Cards, bottom nav |
| `sage` | `#728764` | `#728764` | Secondary text, section labels |
| `cream` | `#f2f4f0` | — | Dividers, borders (light mode) |
| `on-primary` | `#0a1a00` | `#0a1a00` | Text on primary bg |
| `text-primary` | `#1a2a0a` | `#f2f4f0` | Body text |
| `text-secondary` | `#64748b` | `#94a3b8` | Captions, labels |
| `error` | `#ef4444` | `#f87171` | Error states |
| `success` | `#22c55e` | `#4ade80` | Success/completed states |
### 3.2 Typography
| Style | Font | Weight | Size | Usage |
|---|---|---|---|---|
| `displayLarge` | Plus Jakarta Sans | 800 (ExtraBold) | 32sp | Hero numbers (next prayer) |
| `headlineMedium` | Plus Jakarta Sans | 700 (Bold) | 24sp | Screen titles, section headers |
| `titleMedium` | Plus Jakarta Sans | 600 (SemiBold) | 16sp | Card titles, nav labels |
| `bodyLarge` | Plus Jakarta Sans | 400 (Regular) | 16sp | Body text |
| `bodySmall` | Plus Jakarta Sans | 400 (Regular) | 12sp | Captions, timestamps |
| `labelSmall` | Plus Jakarta Sans | 700 (Bold) | 10sp | Uppercase tags, section labels |
| `arabicBody` | Amiri | 400 (Regular) | 24sp | Quran verses, Dzikir Arabic text |
| `arabicLarge` | Amiri | 700 (Bold) | 28sp | Surah headings in Arabic |
### 3.3 Spacing & Shape
| Token | Value | Flutter |
|---|---|---|
| `radiusSm` | 8dp | `BorderRadius.circular(8)` |
| `radiusMd` | 12dp | `BorderRadius.circular(12)` |
| `radiusLg` | 16dp | `BorderRadius.circular(16)` |
| `radiusXl` | 24dp | `BorderRadius.circular(24)` |
| `radiusFull` | 9999dp | `StadiumBorder()` |
| `spacingXs` | 4dp | — |
| `spacingSm` | 8dp | — |
| `spacingMd` | 16dp | — |
| `spacingLg` | 24dp | — |
| `spacingXl` | 32dp | — |
### 3.4 Iconography
- **Library:** Material Symbols Outlined (via `material_symbols_icons` Flutter package)
- **Filled variant:** Used for active bottom nav tab icons only
- **Size:** 24dp default, 20dp for compact rows, 32dp for feature section icons
### 3.5 Dark Mode
- Toggled via user preference in Settings (stored locally)
- Options: **Light**, **Dark**, **System (Auto)**
- Implemented via Flutter `ThemeMode``ThemeMode.light`, `ThemeMode.dark`, `ThemeMode.system`
- All color tokens have explicit light and dark values; no transparency hacks
---
## 4. App Architecture
### 4.1 Structure
```
lib/
├── main.dart
├── app/
│ ├── app.dart # MaterialApp.router + theme + locale
│ ├── router.dart # GoRouter — shell route for bottom nav
│ └── theme/
│ ├── app_theme.dart # ThemeData light + dark
│ ├── app_colors.dart # AppColors class with static consts
│ └── app_text_styles.dart # TextTheme definitions
├── core/
│ ├── widgets/
│ │ ├── bottom_nav_bar.dart
│ │ ├── prayer_time_card.dart
│ │ ├── section_header.dart
│ │ ├── ios_toggle.dart
│ │ ├── progress_bar.dart
│ │ └── circular_progress_indicator_custom.dart
│ ├── utils/
│ │ ├── date_utils.dart
│ │ ├── prayer_utils.dart # Hijri date conversion helpers
│ │ └── arabic_utils.dart # RTL text helpers
│ └── providers/
│ └── theme_provider.dart # ThemeMode state via Riverpod
├── features/
│ ├── dashboard/
│ │ ├── data/
│ │ ├── domain/
│ │ └── presentation/
│ │ ├── dashboard_screen.dart
│ │ └── widgets/
│ ├── imsakiyah/
│ ├── checklist/
│ ├── dzikir/
│ ├── laporan/
│ ├── qibla/
│ ├── quran/
│ │ ├── presentation/
│ │ │ ├── quran_screen.dart # Surah list
│ │ │ ├── quran_reading_screen.dart
│ │ │ └── quran_murattal_screen.dart
│ └── settings/
└── data/
├── local/
│ ├── hive_boxes.dart # Box names constants
│ └── adapters/ # Hive TypeAdapters
└── services/
├── prayer_service.dart # Adhan calculation
├── location_service.dart # GPS + last known
├── notification_service.dart
└── quran_service.dart # Local JSON asset
```
### 4.2 State Management
**Riverpod** (flutter_riverpod + riverpod_annotation + riverpod_generator)
- Every feature has its own `*_provider.dart`
- Async data via `AsyncNotifierProvider`
- UI state (loading, error, data) handled via `AsyncValue`
- Theme mode via `StateProvider<ThemeMode>`
### 4.3 Local Storage
**Hive** for offline-first persistence:
| Box | Key | Value type |
|---|---|---|
| `settings` | String key | dynamic |
| `checklist_items` | date string (yyyy-MM-dd) | `List<ChecklistItem>` |
| `worship_logs` | date string | `WorshipLog` |
| `dzikir_counters` | dzikir ID | int count |
| `cached_prayer_times` | `lat_lng_date` | `PrayerTimes` |
---
## 5. Navigation
### 5.1 Bottom Navigation Bar (5 tabs)
| Index | Label | Icon (inactive) | Icon (active) | Route |
|---|---|---|---|---|
| 0 | Home | `home_outlined` | `home` (filled) | `/` |
| 1 | Calendar | `calendar_today_outlined` | `calendar_today` | `/imsakiyah` |
| 2 | Checklist | `rule_outlined` | `rule` (filled) | `/checklist` |
| 3 | Reports | `bar_chart_outlined` | `bar_chart` | `/laporan` |
| 4 | Tools | `auto_fix_high_outlined` | `auto_fix_high` (filled) | `/tools` |
- Bottom nav is persistent across all 5 tabs (shell route via GoRouter)
- Safe area padding (bottom) applied to nav bar (`pb: 24dp`)
- Active tab: `primary` text + filled icon
- Inactive tab: `text-secondary` color
### 5.2 Full Route Map
```
/ → DashboardScreen
/qibla → QiblaScreen (push, no bottom nav)
/imsakiyah → ImsakiyahScreen
/checklist → ChecklistScreen
/laporan → LaporanScreen
/tools → ToolsScreen (hub for Dzikir, Quran, Qibla)
/tools/dzikir → DzikirScreen
/tools/quran → QuranListScreen
/tools/quran/:surahId → QuranReadingScreen
/tools/quran/:surahId/murattal → QuranMurattalScreen
/tools/qibla → QiblaScreen
/settings → SettingsScreen (push from Dashboard header)
```
---
## 6. Feature Specifications
---
### 6.1 Dashboard
**Route:** `/`
**Description:** App home screen showing next prayer, daily prayer schedule, checklist summary, and weekly progress.
#### UI Components
**Header (sticky)**
- Left: Circular user avatar (40dp, primary border 2dp) + greeting text ("Welcome back," / user name "Assalamu'alaikum, [Name]")
- Right: Notification bell `IconButton` → opens notification settings
**Next Prayer Hero Card**
- Full-width card, `bg-primary`, `borderRadius: 24dp`, `padding: 20dp`, `boxShadow: lg`
- Content:
- Label: "Next Prayer" with `schedule` icon
- Title: `"{PrayerName} at {HH:mm}"``displayLarge` weight 800
- Subtitle: `"Countdown: HH:mm:ss"` — live countdown timer updating every second
- Actions row:
- "View Qibla" button (`black bg`, `white text`, `explore icon`, `borderRadius: full`)
- Volume/mute toggle button (`white/30 backdrop-blur`, `rounded-full`)
- Decorative: white/20 blurred circle blob (top-right, size 120dp)
**Prayer Times Horizontal Scroll**
- Section title: "Daily Prayer Times" + "Today" chip (`primary/10 bg`, `primary text`)
- Horizontal `ListView.builder`, no scroll indicator, `padding: 16dp horizontal`
- Each card: `width: 112dp`, `borderRadius: 16dp`, `border: primary/10`
- Icon (Material Symbol): Fajr→`wb_twilight`, Dhuhr→`wb_sunny`, Asr→`filter_drama`, Maghrib→`wb_twilight`, Isha→`dark_mode`
- Prayer name (`sm font-bold`)
- Time string (`xs text-secondary`)
- **Active prayer card:** `bg-primary/10`, `border-2 border-primary`, primary text color
- **Adhan/Iqamah variant:** Active card shows notification badge (small bell icon, white circle, shadow, absolute top-right)
**Daily Checklist Summary Card**
- Title: "Today's Checklist" + subtitle "{n} dari {total} Ibadah selesai"
- Circular SVG progress indicator (48dp, `strokeWidth: 4`, `primary` stroke)
- Percentage text centered inside circle
- 2 preview items: "Sholat Fardhu (4 of 5)" and "Tilawah Quran (1 Juz)"
- Completed items: `check_circle` icon in `primary` color
- "View Full Checklist" CTA button → navigates to `/checklist`
**Weekly Progress Chart**
- Title: "Weekly Progress"
- `Container` with `bg-surface`, `borderRadius: 16dp`, `padding: 20dp`
- 7 vertical bars (MonSun): each bar is `Expanded`, `bg-primary/20`, with `primary` fill overlay proportional to completion %
- Day labels below each bar: `10sp`, `font-bold`, `text-secondary`
#### Behavior
- Countdown timer refreshes every 1 second via `Timer.periodic`
- Prayer times are calculated from device location via `adhan` package on app start
- If location unavailable, use last cached location; if none, prompt user
- Notification badge on prayer card: shown when Adhan has been called (within current window)
- Tapping the Next Prayer card → navigates to Imsakiyah screen
#### Acceptance Criteria
- [ ] Correct prayer times displayed for device's current GPS location
- [ ] Countdown shows real-time seconds tick
- [ ] Active prayer card highlighted with `primary` border and color
- [ ] Checklist summary reflects today's actual completion state from local storage
- [ ] Weekly chart bars reflect daily worship logs from past 7 days
- [ ] Works fully offline (cached prayer times used when no internet)
---
### 6.2 Prayer Calendar (Imsakiyah)
**Route:** `/imsakiyah`
**Description:** Full Hijri-calendar view of prayer times, organized by month, with location selection.
#### UI Components
**Header**
- Back arrow + "Prayer Calendar" (centered, `headlineMedium`) + `more_vert` menu button
**Month Selector**
- Horizontal scrolling chip row (no scrollbar)
- Selected: `bg-primary`, `text-slate-900`, `font-semibold`, `borderRadius: full`
- Unselected: `bg-surface`, `text-secondary`, `borderRadius: full`
- Months shown in Hijri format: "Ramadan 1445H", "Shawwal 1445H", etc.
**Location Card**
- `bg-surface`, `borderRadius: 16dp`, `border: 1dp`
- `location_on` icon (primary) + "Your Location" label + city name (e.g., "Jakarta, Indonesia")
- `expand_more` chevron — tapping opens city search/picker
**Prayer Times Table**
- 7-column grid: Day | Fajr | Sunrise | Dhuhr | Asr | Maghrib | Isha
- Header row: `bg-primary/10`, `10sp font-bold uppercase tracking-wider text-secondary`
- Data rows: alternating subtle bg for readability
- Today's row: highlighted with `primary/5` background, bold text
#### Behavior
- Loads prayer times for entire selected month using `adhan` package
- Location defaults to last GPS fix; editable via city search
- City search: local bundled list of major Indonesian cities (offline), Geocoding API optional
- Changing month or location recalculates and re-renders table
- Scroll position resets to today's row on initial load
#### Acceptance Criteria
- [ ] Displays complete monthly prayer timetable for selected Hijri month
- [ ] Today's row is visually highlighted
- [ ] Month chip scroll updates table data
- [ ] Location change triggers recalculation
- [ ] Works offline with bundled city coordinates
---
### 6.3 Daily Checklist
**Route:** `/checklist`
**Description:** Daily worship completion tracker with custom checklist items and progress visualization.
#### UI Components
**Header**
- "Daily Worship" (`headlineMedium`) + date string ("Tuesday, 24 Oct") + calendar icon button (date picker)
**Progress Card**
- `bg-slate-900 / bg-primary/10 (dark)`, `borderRadius: 16dp`, `padding: 20dp`
- Decorative `auto_awesome` icon (64sp, opacity 10%, top-right)
- "Today's Goal" label (`xs uppercase tracking-wider text-slate-400`)
- Percentage: "{n}% Complete" (`displayLarge font-bold white`)
- "{completed} / {total} Tasks" (`primary color xs`)
- Progress bar: `h-12dp`, `bg-white/10`, `primary fill`, `borderRadius: full`
- Motivational quote text below bar (`xs text-slate-300`)
**Task List**
Section header: "Religious Tasks" (`sm font-bold uppercase tracking-widest text-secondary`)
Each checklist item:
- `Container` with `bg-surface / bg-primary/5 (dark)`, `borderRadius: 12dp`, `border: 1dp`
- Custom checkbox: 24dp square, `border-2 border-primary/30`, `borderRadius: 6dp`
- Checked state: `bg-primary`, white checkmark SVG inside
- Task label: `bodyLarge font-medium`
- Optional: sub-label (e.g., target count for Tilawah)
**Default checklist items (seeded on first launch):**
| Item | Category | Default Target |
|---|---|---|
| Sholat Fajr | Sholat Fardhu | 1x |
| Sholat Dhuhr | Sholat Fardhu | 1x |
| Sholat Asr | Sholat Fardhu | 1x |
| Sholat Maghrib | Sholat Fardhu | 1x |
| Sholat Isha | Sholat Fardhu | 1x |
| Tilawah Quran | Tilawah | 1 Juz |
| Dzikir Pagi | Dzikir | 1 session |
| Dzikir Petang | Dzikir | 1 session |
| Sholat Sunnah Rawatib | Sunnah | 1x |
| Shodaqoh | Charity | 1x |
#### Behavior
- Checklist resets daily at midnight (new date key in Hive)
- Checking/unchecking an item updates Hive immediately (no "save" button)
- Progress card and percentage update reactively via Riverpod
- Motivational quotes rotate from a bundled list
- User can add/remove/reorder checklist items (edit mode)
- Completion data written to `worship_logs` for use by Reports feature
#### Acceptance Criteria
- [ ] Default 10 items seeded on first launch
- [ ] Checking item updates progress bar + percentage in real time
- [ ] Data persists across app restarts (Hive)
- [ ] New empty checklist created automatically on date change
- [ ] Historical completion accessible by Reports feature
---
### 6.4 Dzikir
**Route:** `/tools/dzikir`
**Description:** Guided morning and evening remembrance (Dzikir) with Arabic text, transliteration, translation, and tap counter.
#### UI Components
**Header (sticky)**
- `back arrow` + "Dzikir Pagi & Petang" (centered, `titleLarge font-bold`) + `info` icon button
- `bg-surface/80`, `backdropFilter: blur(12dp)`, `borderBottom: 1dp primary/10`
**Tab Bar**
- 2 tabs: "Pagi" (Morning) and "Petang" (Evening)
- Active tab: `border-bottom-2 border-primary`, `text-primary`, `font-semibold`
- Inactive tab: `text-secondary`
**Hero Banner**
- `text-center`, `padding: 32dp vertical`
- `bg-gradient(primary/5 → transparent, top → bottom)`
- Title: "Dzikir Pagi / Petang" (`headlineMedium font-bold`)
- Subtitle: context text in Indonesian (`bodySmall text-secondary max-width: 280dp`)
**Dzikir Cards (scrollable list)**
- Each card: `bg-surface`, `borderRadius: 16dp`, `border: 1dp primary/10`, `padding: 20dp`, `margin: 8dp bottom`
- **Arabic text** (`Amiri font`, `24sp`, `RTL direction`, `line-height: 2.0`, `text-right`)
- **Transliteration** (`bodySmall`, `italic`, `text-secondary`, `mt: 12dp`)
- **Translation (Indonesian)** (`bodyMedium`, `text-primary`, `mt: 8dp`)
- **Counter row:**
- "Dibaca: {count} / {target}x" label
- `+` tap button (`bg-primary/10`, `text-primary`, `borderRadius: full`, `size: 40dp`)
- Counter increments on tap; fills to target
- When target reached: button becomes `check_circle` (green), card shows completion glow
#### Behavior
- Default content: bundled local JSON with standard Dzikir Pagi (~20 items) and Dzikir Petang (~20 items)
- Counter state persisted per dzikir per session in Hive (`dzikir_counters` box)
- Counter resets daily (tied to date)
- "Pagi" tab auto-selected between Fajr and Dhuhr; "Petang" between Maghrib and Isha; user can override
- Info button → bottom sheet with brief explanation of dzikir practice
#### Acceptance Criteria
- [ ] Arabic text renders correctly with Amiri font, RTL direction
- [ ] Tap counter increments and persists within the day
- [ ] Counter resets the next day
- [ ] Tab switches between Pagi and Petang content
- [ ] Completion state shown when all counters reach target
---
### 6.5 Reports (Laporan)
**Route:** `/laporan`
**Description:** Visual analytics of worship completion across weekly, monthly, and yearly timeframes.
#### UI Components
**Header**
- Back arrow + "Worship Quality Report" (centered) + `share` icon button
**Tab Bar**
- 3 tabs: Weekly · Monthly · Yearly
- Active: `border-bottom-2 border-primary text-primary`
- Tab bar: `border-bottom: 1dp`
**Main Chart Card**
- `bg-surface`, `borderRadius: 16dp`, `border: 1dp`, `padding: 20dp`
- Header row:
- Left: `analytics` icon badge (`bg-primary/10`, `primary`, `borderRadius: 12dp`, `40dp size`)
- Center: "Daily Completion" label (`bodySmall text-secondary`) + percentage (`displayLarge font-bold`)
- Right: trend chip: `trending_up` icon + delta % (`text-emerald-500` if positive)
- **Bar Chart:**
- `height: 160dp`, flex row of 7 bars (weekly) or 30/12 bars
- Each bar: `Expanded`, `bg-primary/20` track, `bg-primary` fill overlay (proportional to % complete)
- `borderRadius: top only (full)`
- Day/date labels below (`10sp font-bold text-secondary uppercase`)
- Tapping a bar → shows tooltip with exact date + completion %
**Summary Stats Row (below chart)**
- 3 stat cards in a row:
- Best streak: "{n} days" + `local_fire_department` icon
- Average: "{n}%" + `percent` icon
- Total completed: "{n} tasks" + `check_circle` icon
#### Behavior
- Weekly: shows past 7 days (MonSun of current week)
- Monthly: shows all days of current month (30/31 bars, may need scroll)
- Yearly: shows 12 months as bars
- Data sourced from `worship_logs` Hive box (daily completion records)
- Share button: generates a shareable image/text summary of current period stats
#### Acceptance Criteria
- [ ] Correct bar heights proportional to actual daily completion %
- [ ] Tab switching updates chart data and labels
- [ ] Tooltip on bar tap shows date + details
- [ ] Summary stats calculated correctly from logs
- [ ] Empty state when no logs exist ("Start tracking to see your progress")
---
### 6.6 Qibla Finder
**Route:** `/tools/qibla` or `/qibla` (accessible from Dashboard hero card)
**Description:** Compass-based Qibla direction finder showing the direction of Mecca.
#### UI Components
**Header**
- Back arrow + "Qibla Finder" (centered) + `my_location` button (top-right, re-centers GPS)
**Main Content (centered)**
- Location display: `location_on` icon (primary) + city name + Qibla degree ("142.3° from North")
- **Compass widget:**
- Circular compass with N/S/E/W labels
- Rotating needle pointing to Qibla direction
- Mosque silhouette icon overlaid at compass center (fade-to-transparent mask top)
- Green pointer / arrow indicating Qibla direction
- Accuracy indicator: "High accuracy" / "Low accuracy" based on sensor confidence
#### Behavior
- Uses `flutter_qiblah` package for Qibla calculation + `flutter_compass` for device heading
- Rotates compass ring based on device orientation (sensor stream)
- Displays degree from North
- If location permission denied → prompt to enable, fallback to manual city entry
- Mosque silhouette uses a local SVG/image asset with `ShaderMask` fade
#### Acceptance Criteria
- [ ] Compass rotates smoothly with device orientation
- [ ] Qibla arrow points to correct direction based on GPS coordinates
- [ ] Works offline (no internet needed for calculation)
- [ ] Graceful fallback if compass sensor unavailable
---
### 6.7 Quran Reading
**Route:** `/tools/quran``/tools/quran/:surahId`
**Description:** Full Quran reader with Arabic text, Indonesian translation, and verse-by-verse display.
#### UI Components
**Surah List Screen (`/tools/quran`)**
- Search bar at top
- `ListView` of 114 Surahs: number badge + Arabic name + Latin name + verse count + Juz info
**Reading Screen (`/tools/quran/:surahId`)**
Header (sticky, `bg-surface/80 backdrop-blur`):
- Back arrow
- Center column: Surah name (Arabic, `Amiri`) + "Juz {n}" label + verse count
- `more_vert` menu (bookmarks, jump to verse, settings)
**Bismillah banner:** Centered, Arabic font, before verse 1 (except Surah 9)
**Verse Cards:**
- Each verse: `Container`, `bg-surface`, `borderRadius: 12dp`, `padding: 16dp`, `border: 1dp`
- Verse number badge: small circle, `bg-primary/10`, `primary text`, left-aligned
- **Arabic text:** `Amiri`, `28sp`, `RTL`, `line-height: 2.2`, right-aligned, full width
- **Transliteration** (optional, toggled in settings): `bodySmall italic text-secondary`
- **Indonesian translation:** `bodyMedium text-primary`, left-aligned, `mt: 8dp`
- Verse action row: `bookmark`, `share`, `play` (Murattal) icons
#### Behavior
- Quran data: bundled local JSON asset (`assets/quran/quran_id.json`) — 114 surahs, Arabic + Indonesian translation
- Reading position persisted per Surah (last verse read)
- Bookmarks stored in Hive
- "Play" icon on verse → navigates to Murattal screen for that Surah starting at that verse
- Font size adjustable via settings (stored preference)
#### Acceptance Criteria
- [ ] All 114 Surahs accessible
- [ ] Arabic text renders with Amiri font, correct RTL layout
- [ ] Indonesian translation displayed below each verse
- [ ] Reading position saved across app restarts
- [ ] Bookmarking a verse persists in Hive
- [ ] Works fully offline
---
### 6.8 Quran Murattal
**Route:** `/tools/quran/:surahId/murattal`
**Description:** Audio recitation player synchronized with Quran text display.
#### UI Components
**Header:** Same as Quran Reading (Surah name + Juz info)
**Quran Text:** Same as Reading screen — synchronized verse highlight follows audio playback
**Audio Player (bottom persistent panel)**
- Reciter name + surah name
- Progress slider (current position / total duration)
- `skip_previous` | `replay_10` | Play/Pause (`play_circle` / `pause_circle`, 56dp) | `forward_10` | `skip_next`
- Playback speed selector (0.75x, 1x, 1.25x, 1.5x)
- Sleep timer button
#### Behavior
- Audio source: Bundled MP3s for commonly used Surahs (short ones: Al-Fatihah, Juz Amma) — OR streamed from a free Quran audio API (e.g., mp3quran.net)
- Currently playing verse highlighted with `primary/10 bg` + left border accent
- Auto-scrolls to current verse during playback
- Background audio playback (continues when app backgrounded)
- Notification media controls shown in system tray during playback
#### Acceptance Criteria
- [ ] Audio plays and pauses correctly
- [ ] Current verse highlighted in sync with audio (best effort with timed segments)
- [ ] Background playback works (audio continues when screen off)
- [ ] System notification with media controls displayed during playback
- [ ] Playback speed adjustment works
---
### 6.9 Settings
**Route:** `/settings`
**Description:** User profile, notification preferences, display preferences, and app info.
#### UI Components
**Header (sticky)**
- Back arrow + "Settings" (`titleLarge font-bold`) + no right action
**Profile Section**
- Avatar: 64dp circle, `bg-primary/20`, `border-2 border-primary`, initials or photo
- Name: (`titleMedium font-bold`)
- Email: (`bodySmall text-secondary`)
- Edit button (`text-primary`, `edit` icon, top-right of card) → edit name/email inline
**Notification Settings Group**
Label: "NOTIFICATIONS" (`labelSmall uppercase tracking-wider sage color`)
Rows (separated by thin divider):
- **Adhan Notification** — per prayer toggle: Fajr, Dhuhr, Asr, Maghrib, Isha
- **Iqamah Reminder** — offset in minutes (default: 10 min, stepper or picker)
- **Daily Checklist Reminder** — time picker (default: 9:00 AM)
Each row: `leading icon (bg-primary/10, rounded-lg, 40dp)` + `label + subtitle` + **iOS-style toggle**
**iOS-style toggle spec:**
- Size: `51dp × 31dp`
- Track: `bg-cream (off)` / `bg-primary (on)`, `borderRadius: full`
- Thumb: `27dp` white circle, `shadow-md`, animates left↔right on toggle
- Implemented via `AnimatedContainer` + `GestureDetector`
**Display Settings Group**
Label: "DISPLAY"
Rows:
- **Dark Mode**: Light / Dark / Auto (3-way segmented control or cycle toggle)
- **Font Size**: Small / Medium / Large (affects Quran + Dzikir text)
- **Language**: Indonesian / English (UI language, not Quran translation)
**About Group**
Label: "ABOUT"
Rows:
- App Version: "Jamshalat Diary v1.0.0"
- Privacy Policy (launches in-app browser)
- Rate the App (links to store)
- Contact / Feedback
#### Behavior
- All settings persisted in Hive `settings` box immediately on change
- Dark mode change applies instantly (no restart needed) via ThemeMode Riverpod provider
- Notification toggles register/unregister `flutter_local_notifications` channels
- Iqamah offset: default 10 minutes, adjustable per prayer
- Profile name/email stored locally only (no backend account system in v1.0)
#### Acceptance Criteria
- [ ] All toggle states persisted and restored on restart
- [ ] Dark mode applies instantly with animation
- [ ] Adhan/Iqamah notifications schedule correctly based on calculated prayer times
- [ ] Notifications cancel when their toggle is turned off
- [ ] iOS-style toggle animation is smooth (no jank)
---
## 7. Data Model
### 7.1 Hive Boxes & Schemas
```dart
// Settings box (key-value)
class AppSettings {
String userName; // 'Alex Rivers'
String userEmail; // 'alex@example.com'
ThemeMode themeMode; // ThemeMode.system
double arabicFontSize; // 24.0
String uiLanguage; // 'id' | 'en'
Map<String, bool> adhanEnabled; // {'fajr': true, 'dhuhr': true, ...}
Map<String, int> iqamahOffset; // {'fajr': 15, 'dhuhr': 10, ...} in minutes
DateTime? checklistReminderTime;
double? lastLat;
double? lastLng;
String? lastCityName;
}
// Checklist box
@HiveType(typeId: 1)
class ChecklistItem {
String id; // UUID
String title; // 'Sholat Fajr'
String category; // 'sholat_fardhu'
String? subtitle; // '1 Juz'
int sortOrder;
bool isCustom; // false for default items
}
// Daily log box (keyed by 'yyyy-MM-dd')
@HiveType(typeId: 2)
class DailyWorshipLog {
String date; // '2026-03-06'
Map<String, bool> completedItems; // {'item_id': true}
int totalItems;
int completedCount;
double completionPercent;
}
// Dzikir counter box (keyed by 'dzikir_id:yyyy-MM-dd')
@HiveType(typeId: 3)
class DzikirCounter {
String dzikirId;
String date;
int count;
int target;
}
// Bookmarks box
@HiveType(typeId: 4)
class QuranBookmark {
int surahId;
int verseId;
String surahName;
String verseText; // Arabic snippet
DateTime savedAt;
}
// Cached prayer times box (keyed by 'lat_lng_yyyy-MM-dd')
@HiveType(typeId: 5)
class CachedPrayerTimes {
String key;
double lat;
double lng;
String date;
DateTime fajr;
DateTime sunrise;
DateTime dhuhr;
DateTime asr;
DateTime maghrib;
DateTime isha;
}
```
---
## 8. Third-Party Dependencies
| Package | Version | Purpose |
|---|---|---|
| `flutter_riverpod` | ^2.x | State management |
| `riverpod_annotation` | ^2.x | Code gen for providers |
| `go_router` | ^13.x | Declarative navigation |
| `hive_flutter` | ^1.x | Local key-value storage |
| `hive_generator` | ^2.x | Hive TypeAdapter codegen |
| `adhan` | ^1.x | Prayer time calculation (offline) |
| `geolocator` | ^11.x | GPS location |
| `geocoding` | ^3.x | Reverse geocoding (city name) |
| `flutter_qiblah` | ^2.x | Qibla direction calculation |
| `flutter_compass` | ^0.x | Device compass heading |
| `flutter_local_notifications` | ^17.x | Adhan + Iqamah notifications |
| `just_audio` | ^0.x | Quran Murattal audio playback |
| `audio_service` | ^0.x | Background audio + media controls |
| `google_fonts` | ^6.x | Plus Jakarta Sans |
| `material_symbols_icons` | ^4.x | Material Symbols icon set |
| `build_runner` | ^2.x | Code generation runner |
**Quran Data Sources:**
- Arabic text + Indonesian translation: bundled as `assets/quran/quran_id.json` (local, offline)
- Audio (Murattal): streamed from `https://server6.mp3quran.net/` (Mishari Al-Afasy) or bundled short Surahs
---
## 9. Non-Functional Requirements
### 9.1 Performance
- App cold start: < 2 seconds on mid-range devices (Snapdragon 700 series / Apple A14)
- Bottom nav tab switch: < 150ms
- Prayer time calculation: < 50ms (synchronous, offline)
- Quran verse list render: use `ListView.builder` (lazy loading), never `ListView` with all children
### 9.2 Offline-First
- Core features work with no internet: Dashboard, Checklist, Dzikir, Reports, Qibla, Quran Reading, Settings
- Quran Murattal: short Surahs bundled; longer Surahs require connectivity (graceful fallback shown)
- Prayer times: cached per location+date in Hive; recalculated on location change
### 9.3 Accessibility
- Minimum touch target: 48×48dp for all interactive elements
- All icons have semantic labels (`Semantics(label: ...)`)
- Arabic text has `textDirection: TextDirection.rtl` explicitly set
- Sufficient contrast ratios for both light and dark themes (WCAG AA)
### 9.4 Dark Mode
- All screens must look correct in both light and dark mode
- No hardcoded `Colors.white` or `Colors.black` — use `AppColors` tokens only
- Theme transitions are animated (Material 3 `AnimatedTheme`)
### 9.5 Localization
- v1.0: Indonesian (id) as primary language, English (en) as secondary
- Use `flutter_localizations` + `intl` package for date/number formatting
- All UI strings in `AppLocalizations` (ARB files); no hardcoded Indonesian strings in widgets
---
## 10. Permissions
| Permission | Reason | Timing |
|---|---|---|
| `ACCESS_FINE_LOCATION` (Android) / `NSLocationWhenInUseUsageDescription` (iOS) | Prayer time calculation, Qibla direction | On first Dashboard load |
| `POST_NOTIFICATIONS` (Android 13+) | Adhan and Iqamah notifications | On Settings → Notifications first toggle |
| `SCHEDULE_EXACT_ALARM` (Android 12+) | Exact Adhan notification timing | When notification enabled |
| Background audio (iOS: `audio` background mode) | Murattal background playback | On Murattal first play |
**Permission UX:**
- Never request permission without explaining why first (show rationale bottom sheet)
- Graceful degradation: if location denied → manual city picker; if notifications denied → remind once, then respect
---
## 11. Out of Scope (v1.0)
The following features are **explicitly excluded** from v1.0 to keep scope focused:
- User accounts / cloud sync (no backend, local-only)
- Social features (sharing worship progress to social media, leaderboards)
- Full Quran audio library (only short Surahs bundled/streamed)
- Quran memorization (Hafalan) mode
- Hijri calendar widget beyond Imsakiyah
- Community / forum features
- Multi-user / family tracking
- Wear OS / watchOS companion
- Widgets (home screen app widget)
- Apple Watch prayer time complication
- In-app purchases or subscriptions
- Push notifications from a server (only local scheduled notifications)
---
*PRD v1.0 — Jamshalat Diary — March 2026*

17
README.md Normal file
View File

@@ -0,0 +1,17 @@
# jamshalat_diary
A new Flutter project.
## Getting Started
This project is a starting point for a Flutter application.
A few resources to get you started if this is your first Flutter project:
- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter)
- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources)
For help getting started with Flutter development, view the
[online documentation](https://docs.flutter.dev/), which offers tutorials,
samples, guidance on mobile development, and a full API reference.

341
TASKLIST.md Normal file
View File

@@ -0,0 +1,341 @@
# Jamshalat Diary — Phase-Based Implementation Tasklist
**Project:** Jamshalat Diary Flutter App
**PRD Reference:** `PRD.md`
**Design Reference:** `stitch/` folder (18 screens)
**Last Updated:** March 2026
---
## How to Use This Document
- **Status legend:** `[ ]` Not started · `[~]` In progress · `[x]` Done · `[-]` Skipped/deferred
- **Dependency notation:** Tasks marked with `⚠️ depends on: [task ref]` must not start until that task is complete
- **Assignable units:** Each task is atomic enough to delegate to a single agent/developer
- **Phase gates:** Do not start a phase until all `[x]` required tasks in the previous phase are complete
---
## Phase 1 — Foundation
> **Goal:** Runnable Flutter app with navigation shell, design system, and theme engine wired up.
> **Gate to Phase 2:** App boots, bottom nav switches tabs, light/dark toggle works.
### 1.1 Project Bootstrap
- [ ] **1.1.1** Create Flutter project: `flutter create jamshalat_diary --org com.jamshalat --platforms android,ios`
- [ ] **1.1.2** Add all dependencies to `pubspec.yaml` (see PRD §8 — Third-Party Dependencies)
- [ ] **1.1.3** Run `flutter pub get` and verify zero conflicts
- [ ] **1.1.4** Configure `analysis_options.yaml` with strict linting rules (`flutter_lints` + `prefer_const_constructors`)
- [ ] **1.1.5** Set up folder structure as defined in PRD §4.1 (create all `lib/` directories and placeholder files)
- [ ] **1.1.6** Add font assets to `pubspec.yaml`: Google Fonts (Plus Jakarta Sans via `google_fonts` package) + Amiri (bundled TTF in `assets/fonts/`)
- [ ] **1.1.7** Add asset directories to `pubspec.yaml`: `assets/fonts/`, `assets/quran/`, `assets/dzikir/`, `assets/images/`
- [ ] **1.1.8** Configure Android `minSdkVersion: 21`, `targetSdkVersion: 34` in `android/app/build.gradle`
- [ ] **1.1.9** Configure iOS deployment target: 13.0 in `ios/Podfile`
### 1.2 Design System
- [ ] **1.2.1** Create `lib/app/theme/app_colors.dart` — all color tokens from PRD §3.1 (light + dark values as static consts)
- [ ] **1.2.2** Create `lib/app/theme/app_text_styles.dart` — full `TextTheme` from PRD §3.2 (Plus Jakarta Sans weights, Amiri definitions)
- [ ] **1.2.3** Create `lib/app/theme/app_theme.dart``ThemeData` for light and dark using tokens from 1.2.1 + 1.2.2; Material 3 enabled
- [ ] **1.2.4** Create `lib/core/widgets/ios_toggle.dart` — custom iOS-style toggle widget (51×31dp, animated thumb, `AnimatedContainer` + `GestureDetector`) per PRD §6.9
- [ ] **1.2.5** Create `lib/core/widgets/section_header.dart` — reusable section label widget (uppercase, tracking-wider, sage color)
- [ ] **1.2.6** Create `lib/core/widgets/progress_bar.dart` — reusable linear progress bar (primary fill, configurable height + borderRadius)
- [ ] **1.2.7** Verify `AppColors` against all 18 stitch screens — no hardcoded colors in any widget
### 1.3 Navigation Shell
- [ ] **1.3.1** Add `go_router` and set up `lib/app/router.dart` with `ShellRoute` for bottom nav tabs
- [ ] **1.3.2** Define all routes from PRD §5.2 in `router.dart` (top-level + nested)
- [ ] **1.3.3** Create `lib/core/widgets/bottom_nav_bar.dart` — 5-tab nav bar per PRD §5.1 spec (active/inactive states, Material Symbols filled/outlined)
- [ ] **1.3.4** Create stub screens for all 9 features (empty `Scaffold` + `AppBar` with feature name) so routing is testable
- [ ] **1.3.5** Create `lib/app/app.dart``MaterialApp.router` wired to `router.dart` + `themeMode` from Riverpod provider
### 1.4 Theme Engine
- [ ] **1.4.1** Create `lib/core/providers/theme_provider.dart``StateProvider<ThemeMode>` initialized from Hive settings
- [ ] **1.4.2** Wrap `MaterialApp` in `ProviderScope`; consume `themeProvider` for `themeMode`
- [ ] **1.4.3** Verify light ↔ dark toggle visually applies instantly on all stub screens
- [ ] **1.4.4** Create `lib/main.dart` — initializes Hive, runs `ProviderScope` + `App`
---
## Phase 2 — Core Data Layer
> **Goal:** All services and local storage ready. Features can read/write data.
> **Gate to Phase 3:** Prayer times calculate correctly for Jakarta coords, Hive boxes open without errors, notifications schedule in emulator.
> ⚠️ Depends on: Phase 1 complete
### 2.1 Hive Setup
- [ ] **2.1.1** Create all 6 Hive `@HiveType` model classes from PRD §7.1: `AppSettings`, `ChecklistItem`, `DailyWorshipLog`, `DzikirCounter`, `QuranBookmark`, `CachedPrayerTimes`
- [ ] **2.1.2** Run `build_runner` to generate Hive `TypeAdapter` files (`*.g.dart`)
- [ ] **2.1.3** Create `lib/data/local/hive_boxes.dart` — box name constants + `initHive()` function that opens all boxes
- [ ] **2.1.4** Call `initHive()` in `main.dart` before `runApp()`
- [ ] **2.1.5** Seed default `AppSettings` on first launch (if settings box is empty)
- [ ] **2.1.6** Seed default 10 `ChecklistItem` entries on first launch (per PRD §6.3 default items table)
### 2.2 Prayer Time Service
- [ ] **2.2.1** Create `lib/data/services/prayer_service.dart` using `adhan` package
- Method: `PrayerTimes getPrayerTimes(double lat, double lng, DateTime date)`
- Calculation method: Muslim World League (default), configurable in settings
- Returns: Fajr, Sunrise, Dhuhr, Asr, Maghrib, Isha as `DateTime`
- [ ] **2.2.2** Implement cache logic: before calculating, check `CachedPrayerTimes` Hive box for existing `lat_lng_date` key; write result to box after calculation
- [ ] **2.2.3** Create `lib/features/dashboard/data/prayer_times_provider.dart``AsyncNotifierProvider` that fetches location then returns today's `PrayerTimes`
- [ ] **2.2.4** Unit test: `getPrayerTimes(lat: -6.2088, lng: 106.8456, date: 2026-03-06)` → verify Maghrib ≈ 18:05 WIB
### 2.3 Location Service
- [ ] **2.3.1** Create `lib/data/services/location_service.dart`
- Method: `Future<Position?> getCurrentLocation()` — requests permission, gets GPS fix
- Method: `Position? getLastKnownLocation()` — reads from `AppSettings` Hive box
- Method: `Future<String> getCityName(double lat, double lng)` — reverse geocode via `geocoding` package
- [ ] **2.3.2** Implement fallback chain: GPS → last saved → manual city picker
- [ ] **2.3.3** Save successful GPS fix to `AppSettings` (lat, lng, cityName) after each successful fetch
- [ ] **2.3.4** Add `ACCESS_FINE_LOCATION` permission to Android `AndroidManifest.xml`
- [ ] **2.3.5** Add `NSLocationWhenInUseUsageDescription` to iOS `Info.plist`
### 2.4 Notification Service
- [ ] **2.4.1** Create `lib/data/services/notification_service.dart` using `flutter_local_notifications`
- Method: `Future<void> init()` — initializes channels (Android: high-priority Adhan channel)
- Method: `Future<void> scheduleAdhan(Prayer prayer, DateTime time)` — schedules exact alarm
- Method: `Future<void> scheduleIqamah(Prayer prayer, DateTime adhanTime, int offsetMinutes)` — schedules iqamah reminder
- Method: `Future<void> cancelAll()` — cancels all pending notifications
- Method: `Future<void> rescheduleAll(PrayerTimes times, AppSettings settings)` — cancels + re-schedules based on current settings
- [ ] **2.4.2** Add `SCHEDULE_EXACT_ALARM` + `POST_NOTIFICATIONS` permissions to Android manifest
- [ ] **2.4.3** Add iOS background modes for notifications in `Info.plist`
- [ ] **2.4.4** Call `rescheduleAll()` on app start and whenever settings or location changes
- [ ] **2.4.5** Manual test: enable Fajr notification in settings → verify notification fires at correct time in emulator
### 2.5 Quran Data Service
- [ ] **2.5.1** Source or create `assets/quran/quran_id.json` — 114 Surahs, each entry: `{ id, nameArabic, nameLatin, verseCount, juzStart, verses: [{id, arabic, transliteration, translation_id}] }`
- [ ] **2.5.2** Create `lib/data/services/quran_service.dart`
- Method: `Future<List<Surah>> getAllSurahs()` — loads + parses JSON asset (cached in memory after first load)
- Method: `Future<Surah> getSurah(int id)` — returns single surah with all verses
- [ ] **2.5.3** Verify JSON loads correctly; log verse count for Surah 2 (Al-Baqarah) = 286 verses
### 2.6 Dzikir Data Service
- [ ] **2.6.1** Create `assets/dzikir/dzikir_pagi.json` and `assets/dzikir/dzikir_petang.json` — each entry: `{ id, arabic, transliteration, translation, target_count, source }`
- [ ] **2.6.2** Create `lib/data/services/dzikir_service.dart`
- Method: `Future<List<DzikirItem>> getDzikir(DzikirType type)` — loads from JSON asset
- Method: `Map<String, int> getCountersForDate(String date)` — reads from Hive `dzikir_counters` box
- Method: `void increment(String dzikirId, String date)` — writes to Hive
---
## Phase 3 — Dashboard & Prayer Calendar
> **Goal:** The two primary daily-use screens are fully functional.
> **Gate to Phase 4:** Dashboard shows live countdown, correct prayer times, checklist summary. Imsakiyah shows full monthly table.
> ⚠️ Depends on: Phase 2 complete
### 3.1 Dashboard Screen
- [ ] **3.1.1** Build `DashboardScreen` sticky header: avatar + greeting + notification bell (taps → Settings)
- [ ] **3.1.2** Build Next Prayer Hero Card: bg-primary card, prayer name+time (displayLarge), live countdown timer widget
- Countdown: `StreamBuilder` on `Stream.periodic(Duration(seconds: 1))` computing time to next prayer
- [ ] **3.1.3** Build Adhan/Iqamah badge: notification bell icon absolutely positioned on active prayer card (shown when Adhan has been called, hidden otherwise)
- [ ] **3.1.4** Build "View Qibla" button on hero card → navigates to `/tools/qibla`
- [ ] **3.1.5** Build Prayer Times horizontal scroll: `ListView.builder` horizontal, 5 prayer cards (FajrIsha), active card highlighted
- [ ] **3.1.6** Build Today's Checklist Summary Card: circular SVG progress + 2 preview items + "View Full Checklist" CTA
- [ ] **3.1.7** Build Weekly Progress bar chart: 7 bars (MonSun), data from `worship_logs` Hive box
- [ ] **3.1.8** Wire `prayer_times_provider` to all prayer time displays
- [ ] **3.1.9** Wire checklist summary to today's `DailyWorshipLog` from Hive
- [ ] **3.1.10** Verify screen matches `stitch/dashboard_active_nav/screen.png` visually
### 3.2 Prayer Calendar (Imsakiyah) Screen
- [ ] **3.2.1** Build Imsakiyah screen header: back + "Prayer Calendar" + `more_vert`
- [ ] **3.2.2** Build Hijri month selector: horizontal scroll chip row; compute current + surrounding Hijri months
- [ ] **3.2.3** Build location card: shows current city name + `expand_more` tapping opens city search bottom sheet
- [ ] **3.2.4** Build city search bottom sheet: `TextField` + `ListView` of bundled Indonesian cities (local JSON asset)
- [ ] **3.2.5** Build prayer times table: `GridView` 7-column, header row, data rows for all days of selected Hijri month
- [ ] **3.2.6** Highlight today's row with `primary/5` background
- [ ] **3.2.7** Wire month selector + location to `PrayerService` — recalculate on change
- [ ] **3.2.8** Auto-scroll to today's row on screen open
- [ ] **3.2.9** Verify screen matches `stitch/imsakiyah_active_nav/screen.png` visually
---
## Phase 4 — Worship Tracking
> **Goal:** Checklist, Dzikir, and Reports fully functional with persistent data.
> **Gate to Phase 5:** Checklist persists across restarts, Dzikir counter works, Reports chart renders from real log data.
> ⚠️ Depends on: Phase 2 complete (data layer), Phase 3 (worship_logs written by Dashboard)
### 4.1 Daily Checklist Screen
- [ ] **4.1.1** Build Checklist header: "Daily Worship" + date string + calendar icon (date picker for viewing past days)
- [ ] **4.1.2** Build Progress Card: dark bg, `auto_awesome` decoration, percentage text, progress bar, motivational quote
- [ ] **4.1.3** Build task list: `ListView` of `ChecklistItem` widgets with custom checkbox
- [ ] **4.1.4** Implement custom checkbox widget: 24dp, primary border, animated check SVG on tap
- [ ] **4.1.5** Wire tap → update `DailyWorshipLog` in Hive → Riverpod provider invalidate → progress card updates reactively
- [ ] **4.1.6** Implement daily reset: on date change, create new empty `DailyWorshipLog` for new date
- [ ] **4.1.7** Write completion data to `worship_logs` box (for Reports feature to consume)
- [ ] **4.1.8** Verify screen matches `stitch/checklist_active_nav/screen.png` and `stitch/checklist_dark_mode/screen.png`
### 4.2 Dzikir Screen
- [ ] **4.2.1** Build Dzikir header: back + "Dzikir Pagi & Petang" + info button (bottom sheet explanation)
- [ ] **4.2.2** Build Pagi/Petang tab bar with animated underline indicator
- [ ] **4.2.3** Build hero banner: gradient bg + title + subtitle
- [ ] **4.2.4** Build Dzikir card: Arabic text (Amiri, RTL, 24sp), transliteration (italic), translation, counter row
- [ ] **4.2.5** Implement tap counter: `+` button → increments count in Hive → rebuilds counter row reactively
- [ ] **4.2.6** Implement completion state: when `count >= target`, button becomes `check_circle`, card shows subtle primary glow
- [ ] **4.2.7** Implement smart tab pre-selection: Pagi between FajrDhuhr, Petang between MaghribIsha
- [ ] **4.2.8** Verify Arabic text renders correctly RTL, no overflow, no visual glitches
- [ ] **4.2.9** Verify screen matches `stitch/dzikir_active_nav/screen.png` and `stitch/dzikir_dark_mode/screen.png`
### 4.3 Reports (Laporan) Screen
- [ ] **4.3.1** Build Reports header: back + "Worship Quality Report" + share button
- [ ] **4.3.2** Build Weekly/Monthly/Yearly tab bar
- [ ] **4.3.3** Build main chart card: analytics badge + completion % + trend chip
- [ ] **4.3.4** Build bar chart widget: custom `CustomPainter` or `Column`+`Expanded` bars, proportional heights from log data
- [ ] **4.3.5** Implement tap-on-bar tooltip: `OverlayEntry` or `Tooltip` showing date + exact % on tap
- [ ] **4.3.6** Build summary stats row: Best streak, Average %, Total completed
- [ ] **4.3.7** Implement streak calculation: consecutive days with completion > 0% from `worship_logs`
- [ ] **4.3.8** Implement share button: generate shareable text summary (no image generation in v1.0)
- [ ] **4.3.9** Build empty state: illustration + "Start tracking to see your progress" when no logs exist
- [ ] **4.3.10** Verify screen matches `stitch/laporan_active_nav/screen.png` and `stitch/laporan_dark_mode/screen.png`
---
## Phase 5 — Islamic Tools
> **Goal:** Qibla, Quran Reader, and Murattal player fully functional.
> **Gate to Phase 6:** Qibla compass rotates with device, Quran displays all 114 Surahs, audio plays in background.
> ⚠️ Depends on: Phase 2 complete (data layer)
### 5.1 Qibla Finder Screen
- [ ] **5.1.1** Build Qibla header: back + "Qibla Finder" + `my_location` button
- [ ] **5.1.2** Build location + degree display: city name + `"{n}°} from North"` label
- [ ] **5.1.3** Build compass widget: circular ring with N/S/E/W labels, `AnimatedRotation` driven by `flutter_compass` stream
- [ ] **5.1.4** Build Qibla arrow: overlaid pointer that stays fixed to calculated Qibla bearing while compass ring rotates
- [ ] **5.1.5** Build mosque silhouette overlay: `ShaderMask` with gradient mask (fade-to-transparent at top)
- [ ] **5.1.6** Build accuracy indicator: "High accuracy" / "Low accuracy" label based on sensor data
- [ ] **5.1.7** Build permission-denied state: explanation card + "Open Settings" button
- [ ] **5.1.8** Verify compass direction is correct for Jakarta coordinates (Qibla ≈ 295° from North)
### 5.2 Quran List Screen
- [ ] **5.2.1** Build Quran list screen header: search bar at top
- [ ] **5.2.2** Build Surah list: `ListView.builder` of 114 items
- Each item: number badge (primary/10 bg) + Arabic name (Amiri) + Latin name + verse count + Juz
- [ ] **5.2.3** Implement search filter: filters by Latin name or Surah number in real time
### 5.3 Quran Reading Screen
- [ ] **5.3.1** Build reading screen sticky header: back + Surah name (Arabic, Amiri) + Juz info + `more_vert`
- [ ] **5.3.2** Build Bismillah banner (shown for all Surahs except Surah 9 — At-Tawbah)
- [ ] **5.3.3** Build verse card: verse number badge + Arabic text (Amiri, 28sp, RTL) + transliteration + translation
- [ ] **5.3.4** Build verse action row: `bookmark`, `share`, `play` icons per verse
- [ ] **5.3.5** Implement bookmarking: tap bookmark icon → save `QuranBookmark` to Hive → icon toggles to filled
- [ ] **5.3.6** Implement reading position persistence: save last scroll position (verse index) to Hive per Surah
- [ ] **5.3.7** Wire `play` icon on verse → navigate to `/tools/quran/:surahId/murattal` starting at that verse
- [ ] **5.3.8** Verify Arabic text is RTL, no clipping, line-height comfortable (2.2 per PRD)
- [ ] **5.3.9** Verify screen matches `stitch/quran_reading_active_nav/screen.png` and `stitch/quran_dark_mode/screen.png`
### 5.4 Quran Murattal Screen
- [ ] **5.4.1** Initialize `just_audio` player + `audio_service` for background playback
- [ ] **5.4.2** Build Murattal screen: inherits Quran Reading layout + adds audio player panel at bottom
- [ ] **5.4.3** Build audio player panel: reciter name + progress slider + transport controls (`skip_previous`, `replay_10`, play/pause, `forward_10`, `skip_next`)
- [ ] **5.4.4** Build playback speed selector: 0.75x / 1x / 1.25x / 1.5x chips
- [ ] **5.4.5** Implement verse highlight sync: highlight current verse based on audio position (timed segments per Surah)
- [ ] **5.4.6** Implement auto-scroll: `ScrollController` scrolls to current verse during playback
- [ ] **5.4.7** Configure background audio: `audio_service` handler, system media controls in notification tray
- [ ] **5.4.8** Bundle short Surahs (Juz Amma: Surah 78114) as local MP3 assets
- [ ] **5.4.9** Implement streaming fallback for longer Surahs (mp3quran.net API)
- [ ] **5.4.10** Build offline fallback state for streamed Surahs: "Connect to internet to play this Surah"
- [ ] **5.4.11** Verify screen matches `stitch/quran_murattal_active_nav/screen.png`
---
## Phase 6 — Settings & System Polish
> **Goal:** Settings screen complete, all toggles wired, notifications working end-to-end, dark mode seamless.
> **Gate to Phase 7:** All settings persist, dark mode switches instantly, Adhan notification fires correctly, no hardcoded colors remain.
> ⚠️ Depends on: Phase 4 + Phase 5 complete
### 6.1 Settings Screen
- [ ] **6.1.1** Build Settings header: back + "Settings" title
- [ ] **6.1.2** Build Profile section: avatar + name + email + edit button (inline editing)
- [ ] **6.1.3** Build Notifications group: per-prayer Adhan toggles (5 prayers) using `IosToggle` widget
- [ ] **6.1.4** Build Iqamah offset row: per-prayer minute offset picker (stepper, default 10 min)
- [ ] **6.1.5** Build Daily Checklist Reminder row: time picker, default 9:00 AM
- [ ] **6.1.6** Build Display group: Dark Mode 3-way control (Light/Dark/Auto), Font Size selector, Language selector
- [ ] **6.1.7** Build About group: App Version, Privacy Policy (in-app WebView), Rate App link, Feedback
- [ ] **6.1.8** Wire all toggles → write to `AppSettings` Hive box → trigger side effects (notification reschedule, theme change)
- [ ] **6.1.9** Verify screen matches `stitch/settings_with_dark_mode_option/screen.png` and `stitch/settings_dark_mode/screen.png`
### 6.2 Dark Mode Polish
- [ ] **6.2.1** Audit ALL screens in dark mode against `stitch/*_dark_mode/screen.png` — fix any color token mismatches
- [ ] **6.2.2** Verify no hardcoded `Colors.white`, `Colors.black`, or raw hex strings remain in widget code (grep codebase)
- [ ] **6.2.3** Verify `AnimatedTheme` transition is smooth (no flash/jank on toggle)
- [ ] **6.2.4** Test system auto mode: app follows device dark mode setting correctly
### 6.3 Notification End-to-End
- [ ] **6.3.1** Verify Adhan notifications fire at correct prayer times on Android emulator (API 34)
- [ ] **6.3.2** Verify Adhan notifications fire correctly on iOS simulator (iOS 16+)
- [ ] **6.3.3** Verify notifications are cancelled when prayer toggle is turned off in Settings
- [ ] **6.3.4** Verify Iqamah notification fires `n` minutes after Adhan (configurable offset)
- [ ] **6.3.5** Verify notifications reschedule correctly after location changes (new prayer times → cancel old → schedule new)
- [ ] **6.3.6** Verify tapping notification → opens app on Dashboard screen
### 6.4 Accessibility Pass
- [ ] **6.4.1** Audit all `IconButton`s and small tap targets: minimum 48×48dp (add `SizedBox` wrappers where needed)
- [ ] **6.4.2** Add `Semantics(label: ...)` to all icon-only buttons across all screens
- [ ] **6.4.3** Verify all Arabic text has explicit `textDirection: TextDirection.rtl`
- [ ] **6.4.4** Verify contrast ratios for all text on primary bg (`#70df20` background with dark text = check)
- [ ] **6.4.5** Test with TalkBack (Android) and VoiceOver (iOS) — all interactive elements must be reachable
### 6.5 Localization
- [ ] **6.5.1** Set up `flutter_localizations` + `intl` in `pubspec.yaml`
- [ ] **6.5.2** Create `lib/l10n/app_id.arb` (Indonesian) and `lib/l10n/app_en.arb` (English) with all UI strings
- [ ] **6.5.3** Replace all hardcoded Indonesian strings in widgets with `AppLocalizations.of(context).*`
- [ ] **6.5.4** Verify language switch in Settings changes all UI strings (no restart required)
---
## Phase 7 — QA & Release Prep
> **Goal:** App is production-ready. All screens verified, performance validated, store assets ready.
> **Gate to ship:** Zero critical bugs, performance targets met, store listing complete.
> ⚠️ Depends on: Phase 6 complete
### 7.1 Integration Testing
- [ ] **7.1.1** Write integration test: full checklist flow (open app → check 5 items → verify progress = 50% → restart → verify persisted)
- [ ] **7.1.2** Write integration test: prayer time accuracy for 5 major Indonesian cities (Jakarta, Surabaya, Medan, Makassar, Denpasar)
- [ ] **7.1.3** Write integration test: dark mode toggle persists across app restart
- [ ] **7.1.4** Write integration test: Dzikir counter increments and resets on next day
- [ ] **7.1.5** Manual test: Quran reads all 114 Surahs without crash (test Surah 2 — largest Surah)
- [ ] **7.1.6** Manual test: Murattal audio plays + background playback continues when screen locked
### 7.2 Performance Audit
- [ ] **7.2.1** Profile cold start time on mid-range Android device — must be < 2 seconds
- [ ] **7.2.2** Profile bottom nav tab switches — must be < 150ms
- [ ] **7.2.3** Profile Quran Surah 2 scroll (286 verses) — must maintain 60fps
- [ ] **7.2.4** Run `flutter analyze` — zero warnings, zero errors
- [ ] **7.2.5** Run `flutter test` — all unit tests pass
### 7.3 App Assets & Store Prep
- [ ] **7.3.1** Create app icon (1024×1024px): mosque/compass motif with `#70df20` primary color
- [ ] **7.3.2** Apply app icon via `flutter_launcher_icons` package (all densities, adaptive icon for Android)
- [ ] **7.3.3** Create splash screen via `flutter_native_splash` package (white/dark bg, centered logo)
- [ ] **7.3.4** Set app name: "Jamshalat Diary" in `AndroidManifest.xml` and `Info.plist`
- [ ] **7.3.5** Set bundle ID: `com.jamshalat.diary` on both platforms
- [ ] **7.3.6** Configure release signing (Android keystore, iOS certificates) — document in private README
- [ ] **7.3.7** Build release APK: `flutter build apk --release` — verify no build errors
- [ ] **7.3.8** Build iOS release: `flutter build ipa --release` — verify no build errors
- [ ] **7.3.9** Write Play Store listing: app description (Indonesian + English), screenshots (1 per key screen), tags
- [ ] **7.3.10** Write App Store listing: same content, App Store Connect metadata
---
## Progress Tracker
| Phase | Total Tasks | Done | Remaining | Status |
|---|---|---|---|---|
| Phase 1 — Foundation | 22 | 0 | 22 | Not started |
| Phase 2 — Data Layer | 21 | 0 | 21 | Not started |
| Phase 3 — Dashboard & Calendar | 18 | 0 | 18 | Not started |
| Phase 4 — Worship Tracking | 28 | 0 | 28 | Not started |
| Phase 5 — Islamic Tools | 27 | 0 | 27 | Not started |
| Phase 6 — Settings & Polish | 22 | 0 | 22 | Not started |
| Phase 7 — QA & Release | 20 | 0 | 20 | Not started |
| **TOTAL** | **158** | **0** | **158** | 🔴 Not started |
---
## Quick Reference — Key Files
| File | Purpose |
|---|---|
| `PRD.md` | Full product requirements — source of truth |
| `stitch/*/screen.png` | Visual reference for each screen |
| `stitch/*/code.html` | HTML implementation reference for each screen |
| `TASKLIST.md` | This document — execution plan |
---
*TASKLIST v1.0 — Jamshalat Diary — March 2026*

14
analysis_options.yaml Normal file
View File

@@ -0,0 +1,14 @@
include: package:flutter_lints/flutter.yaml
linter:
rules:
prefer_const_constructors: true
prefer_const_declarations: true
prefer_const_literals_to_create_immutables: true
prefer_final_fields: true
prefer_final_locals: true
avoid_print: true
avoid_unnecessary_containers: true
sized_box_for_whitespace: true
use_key_in_widget_constructors: true
prefer_single_quotes: true

11
android/app/build.gradle Normal file
View File

@@ -0,0 +1,11 @@
android {
compileSdkVersion 34
defaultConfig {
applicationId "com.jamshalat.diary"
minSdkVersion 21
targetSdkVersion 34
versionCode 1
versionName "1.0.0"
}
}

View File

@@ -0,0 +1,84 @@
package io.flutter.plugins;
import androidx.annotation.Keep;
import androidx.annotation.NonNull;
import io.flutter.Log;
import io.flutter.embedding.engine.FlutterEngine;
/**
* Generated file. Do not edit.
* This file is generated by the Flutter tool based on the
* plugins that support the Android platform.
*/
@Keep
public final class GeneratedPluginRegistrant {
private static final String TAG = "GeneratedPluginRegistrant";
public static void registerWith(@NonNull FlutterEngine flutterEngine) {
try {
flutterEngine.getPlugins().add(new com.ryanheise.audioservice.AudioServicePlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin audio_service, com.ryanheise.audioservice.AudioServicePlugin", e);
}
try {
flutterEngine.getPlugins().add(new com.ryanheise.audio_session.AudioSessionPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin audio_session, com.ryanheise.audio_session.AudioSessionPlugin", e);
}
try {
flutterEngine.getPlugins().add(new com.hemanthraj.fluttercompass.FlutterCompassPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin flutter_compass, com.hemanthraj.fluttercompass.FlutterCompassPlugin", e);
}
try {
flutterEngine.getPlugins().add(new com.hemanthraj.fluttercompass.FlutterCompassPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin flutter_compass_v2, com.hemanthraj.fluttercompass.FlutterCompassPlugin", e);
}
try {
flutterEngine.getPlugins().add(new com.dexterous.flutterlocalnotifications.FlutterLocalNotificationsPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin flutter_local_notifications, com.dexterous.flutterlocalnotifications.FlutterLocalNotificationsPlugin", e);
}
try {
flutterEngine.getPlugins().add(new ml.medyas.flutter_qiblah.FlutterQiblahPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin flutter_qiblah, ml.medyas.flutter_qiblah.FlutterQiblahPlugin", e);
}
try {
flutterEngine.getPlugins().add(new com.baseflow.geocoding.GeocodingPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin geocoding_android, com.baseflow.geocoding.GeocodingPlugin", e);
}
try {
flutterEngine.getPlugins().add(new com.baseflow.geolocator.GeolocatorPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin geolocator_android, com.baseflow.geolocator.GeolocatorPlugin", e);
}
try {
flutterEngine.getPlugins().add(new com.ryanheise.just_audio.JustAudioPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin just_audio, com.ryanheise.just_audio.JustAudioPlugin", e);
}
try {
flutterEngine.getPlugins().add(new dev.fluttercommunity.plus.packageinfo.PackageInfoPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin package_info_plus, dev.fluttercommunity.plus.packageinfo.PackageInfoPlugin", e);
}
try {
flutterEngine.getPlugins().add(new io.flutter.plugins.pathprovider.PathProviderPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin path_provider_android, io.flutter.plugins.pathprovider.PathProviderPlugin", e);
}
try {
flutterEngine.getPlugins().add(new com.tekartik.sqflite.SqflitePlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin sqflite_android, com.tekartik.sqflite.SqflitePlugin", e);
}
try {
flutterEngine.getPlugins().add(new io.flutter.plugins.urllauncher.UrlLauncherPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin url_launcher_android, io.flutter.plugins.urllauncher.UrlLauncherPlugin", e);
}
}
}

2
android/local.properties Normal file
View File

@@ -0,0 +1,2 @@
sdk.dir=/Users/dwindown/Library/Android/sdk
flutter.sdk=/Users/dwindown/FlutterDev/flutter

View File

@@ -0,0 +1,58 @@
[
{
"id": "pagi_01",
"arabic": "أَعُوذُ بِاللَّهِ مِنَ الشَّيْطَانِ الرَّجِيمِ",
"transliteration": "A'udzu billahi minasy syaithanir rajiim",
"translation": "Aku berlindung kepada Allah dari godaan syaitan yang terkutuk",
"target_count": 1,
"source": "HR. Abu Dawud"
},
{
"id": "pagi_02",
"arabic": "بِسْمِ اللَّهِ الرَّحْمَنِ الرَّحِيمِ",
"transliteration": "Bismillahir rahmaanir rahiim",
"translation": "Dengan nama Allah Yang Maha Pengasih lagi Maha Penyayang",
"target_count": 1,
"source": ""
},
{
"id": "pagi_03",
"arabic": "أَصْبَحْنَا وَأَصْبَحَ الْمُلْكُ لِلَّهِ، وَالْحَمْدُ لِلَّهِ",
"transliteration": "Ashbahnaa wa ashbahal mulku lillah, walhamdu lillah",
"translation": "Kami telah memasuki waktu pagi dan kerajaan hanya milik Allah, segala puji bagi Allah",
"target_count": 1,
"source": "HR. Muslim"
},
{
"id": "pagi_04",
"arabic": "اللَّهُمَّ بِكَ أَصْبَحْنَا وَبِكَ أَمْسَيْنَا وَبِكَ نَحْيَا وَبِكَ نَمُوتُ وَإِلَيْكَ النُّشُورُ",
"transliteration": "Allahumma bika ashbahnaa wa bika amsainaa wa bika nahyaa wa bika namuutu wa ilaikan nusyuur",
"translation": "Ya Allah, dengan rahmat dan pertolongan-Mu kami memasuki waktu pagi, dan dengan rahmat-Mu kami memasuki waktu petang",
"target_count": 1,
"source": "HR. Tirmidzi"
},
{
"id": "pagi_05",
"arabic": "سُبْحَانَ اللَّهِ وَبِحَمْدِهِ",
"transliteration": "Subhanallahi wa bihamdihi",
"translation": "Maha Suci Allah dan dengan memuji-Nya",
"target_count": 100,
"source": "HR. Muslim"
},
{
"id": "pagi_06",
"arabic": "لاَ إِلَهَ إِلاَّ اللَّهُ وَحْدَهُ لاَ شَرِيكَ لَهُ",
"transliteration": "Laa ilaaha illallahu wahdahu laa syariika lahu",
"translation": "Tiada Tuhan selain Allah semata, tidak ada sekutu bagi-Nya",
"target_count": 10,
"source": "HR. Bukhari & Muslim"
},
{
"id": "pagi_07",
"arabic": "أَسْتَغْفِرُ اللَّهَ وَأَتُوبُ إِلَيْهِ",
"transliteration": "Astaghfirullaha wa atuubu ilaihi",
"translation": "Aku memohon ampun kepada Allah dan bertaubat kepada-Nya",
"target_count": 100,
"source": "HR. Bukhari & Muslim"
}
]

View File

@@ -0,0 +1,58 @@
[
{
"id": "petang_01",
"arabic": "أَعُوذُ بِاللَّهِ مِنَ الشَّيْطَانِ الرَّجِيمِ",
"transliteration": "A'udzu billahi minasy syaithanir rajiim",
"translation": "Aku berlindung kepada Allah dari godaan syaitan yang terkutuk",
"target_count": 1,
"source": "HR. Abu Dawud"
},
{
"id": "petang_02",
"arabic": "بِسْمِ اللَّهِ الرَّحْمَنِ الرَّحِيمِ",
"transliteration": "Bismillahir rahmaanir rahiim",
"translation": "Dengan nama Allah Yang Maha Pengasih lagi Maha Penyayang",
"target_count": 1,
"source": ""
},
{
"id": "petang_03",
"arabic": "أَمْسَيْنَا وَأَمْسَى الْمُلْكُ لِلَّهِ، وَالْحَمْدُ لِلَّهِ",
"transliteration": "Amsainaa wa amsal mulku lillah, walhamdu lillah",
"translation": "Kami telah memasuki waktu petang dan kerajaan hanya milik Allah, segala puji bagi Allah",
"target_count": 1,
"source": "HR. Muslim"
},
{
"id": "petang_04",
"arabic": "اللَّهُمَّ بِكَ أَمْسَيْنَا وَبِكَ أَصْبَحْنَا وَبِكَ نَحْيَا وَبِكَ نَمُوتُ وَإِلَيْكَ الْمَصِيرُ",
"transliteration": "Allahumma bika amsainaa wa bika ashbahnaa wa bika nahyaa wa bika namuutu wa ilaikal mashiir",
"translation": "Ya Allah, dengan rahmat-Mu kami memasuki waktu petang, dan dengan rahmat-Mu kami memasuki waktu pagi",
"target_count": 1,
"source": "HR. Tirmidzi"
},
{
"id": "petang_05",
"arabic": "سُبْحَانَ اللَّهِ وَبِحَمْدِهِ",
"transliteration": "Subhanallahi wa bihamdihi",
"translation": "Maha Suci Allah dan dengan memuji-Nya",
"target_count": 100,
"source": "HR. Muslim"
},
{
"id": "petang_06",
"arabic": "لاَ إِلَهَ إِلاَّ اللَّهُ وَحْدَهُ لاَ شَرِيكَ لَهُ",
"transliteration": "Laa ilaaha illallahu wahdahu laa syariika lahu",
"translation": "Tiada Tuhan selain Allah semata, tidak ada sekutu bagi-Nya",
"target_count": 10,
"source": "HR. Bukhari & Muslim"
},
{
"id": "petang_07",
"arabic": "أَسْتَغْفِرُ اللَّهَ وَأَتُوبُ إِلَيْهِ",
"transliteration": "Astaghfirullaha wa atuubu ilaihi",
"translation": "Aku memohon ampun kepada Allah dan bertaubat kepada-Nya",
"target_count": 100,
"source": "HR. Bukhari & Muslim"
}
]

View File

@@ -0,0 +1 @@
Placeholder for Amiri-Bold.ttf

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

100
assets/quran/quran_id.json Normal file
View File

@@ -0,0 +1,100 @@
[
{
"id": 1,
"name_arabic": "الفاتحة",
"name_latin": "Al-Fatihah",
"verse_count": 7,
"juz_start": 1,
"revelation_type": "Meccan",
"verses": [
{
"id": 1,
"arabic": "بِسْمِ اللَّهِ الرَّحْمَـٰنِ الرَّحِيمِ",
"transliteration": "Bismillaahir Rahmaanir Raheem",
"translation_id": "Dengan nama Allah Yang Maha Pengasih lagi Maha Penyayang"
},
{
"id": 2,
"arabic": "الْحَمْدُ لِلَّهِ رَبِّ الْعَالَمِينَ",
"transliteration": "Alhamdu lillaahi Rabbil 'aalameen",
"translation_id": "Segala puji bagi Allah, Tuhan seluruh alam"
},
{
"id": 3,
"arabic": "الرَّحْمَـٰنِ الرَّحِيمِ",
"transliteration": "Ar-Rahmaanir-Raheem",
"translation_id": "Yang Maha Pengasih lagi Maha Penyayang"
},
{
"id": 4,
"arabic": "مَالِكِ يَوْمِ الدِّينِ",
"transliteration": "Maaliki Yawmid-Deen",
"translation_id": "Pemilik hari pembalasan"
},
{
"id": 5,
"arabic": "إِيَّاكَ نَعْبُدُ وَإِيَّاكَ نَسْتَعِينُ",
"transliteration": "Iyyaaka na'budu wa lyyaaka nasta'een",
"translation_id": "Hanya kepada Engkaulah kami menyembah dan hanya kepada Engkaulah kami mohon pertolongan"
},
{
"id": 6,
"arabic": "اهْدِنَا الصِّرَاطَ الْمُسْتَقِيمَ",
"transliteration": "Ihdinas-Siraatal-Mustaqeem",
"translation_id": "Tunjukilah kami jalan yang lurus"
},
{
"id": 7,
"arabic": "صِرَاطَ الَّذِينَ أَنْعَمْتَ عَلَيْهِمْ غَيْرِ الْمَغْضُوبِ عَلَيْهِمْ وَلَا الضَّالِّينَ",
"transliteration": "Siraatal-lazeena an'amta 'alaihim ghayril-maghdoobi 'alaihim wa lad-daaalleen",
"translation_id": "Yaitu jalan orang-orang yang telah Engkau beri nikmat kepadanya; bukan jalan mereka yang dimurkai dan bukan pula jalan mereka yang sesat"
}
]
},
{
"id": 114,
"name_arabic": "النَّاس",
"name_latin": "An-Nas",
"verse_count": 6,
"juz_start": 30,
"revelation_type": "Meccan",
"verses": [
{
"id": 1,
"arabic": "قُلْ أَعُوذُ بِرَبِّ النَّاسِ",
"transliteration": "Qul a'uudzu birabbinnaas",
"translation_id": "Katakanlah: Aku berlindung kepada Tuhannya manusia"
},
{
"id": 2,
"arabic": "مَلِكِ النَّاسِ",
"transliteration": "Malikinnaas",
"translation_id": "Raja manusia"
},
{
"id": 3,
"arabic": "إِلَـٰهِ النَّاسِ",
"transliteration": "Ilaahinnaas",
"translation_id": "Sembahan manusia"
},
{
"id": 4,
"arabic": "مِن شَرِّ الْوَسْوَاسِ الْخَنَّاسِ",
"transliteration": "Min syarril waswaasil khannaas",
"translation_id": "Dari kejahatan bisikan syaitan yang biasa bersembunyi"
},
{
"id": 5,
"arabic": "الَّذِي يُوَسْوِسُ فِي صُدُورِ النَّاسِ",
"transliteration": "Allazii yuwaswisu fii suduurinnaas",
"translation_id": "Yang membisikkan kejahatan ke dalam dada manusia"
},
{
"id": 6,
"arabic": "مِنَ الْجِنَّةِ وَالنَّاسِ",
"transliteration": "Minal jinnati wannaas",
"translation_id": "Dari golongan jin dan manusia"
}
]
}
]

View File

@@ -0,0 +1,14 @@
// 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
COCOAPODS_PARALLEL_CODE_SIGN=true
FLUTTER_TARGET=lib/main.dart
FLUTTER_BUILD_DIR=build
FLUTTER_BUILD_NAME=1.0.0
FLUTTER_BUILD_NUMBER=1
EXCLUDED_ARCHS[sdk=iphonesimulator*]=i386
EXCLUDED_ARCHS[sdk=iphoneos*]=armv7
DART_OBFUSCATION=false
TRACK_WIDGET_CREATION=true
TREE_SHAKE_ICONS=false
PACKAGE_CONFIG=.dart_tool/package_config.json

View File

@@ -0,0 +1,32 @@
#
# Generated file, do not edit.
#
import lldb
def handle_new_rx_page(frame: lldb.SBFrame, bp_loc, extra_args, intern_dict):
"""Intercept NOTIFY_DEBUGGER_ABOUT_RX_PAGES and touch the pages."""
base = frame.register["x0"].GetValueAsAddress()
page_len = frame.register["x1"].GetValueAsUnsigned()
# Note: NOTIFY_DEBUGGER_ABOUT_RX_PAGES will check contents of the
# first page to see if handled it correctly. This makes diagnosing
# misconfiguration (e.g. missing breakpoint) easier.
data = bytearray(page_len)
data[0:8] = b'IHELPED!'
error = lldb.SBError()
frame.GetThread().GetProcess().WriteMemory(base, data, error)
if not error.Success():
print(f'Failed to write into {base}[+{page_len}]', error)
return
def __lldb_init_module(debugger: lldb.SBDebugger, _):
target = debugger.GetDummyTarget()
# Caveat: must use BreakpointCreateByRegEx here and not
# BreakpointCreateByName. For some reasons callback function does not
# get carried over from dummy target for the later.
bp = target.BreakpointCreateByRegex("^NOTIFY_DEBUGGER_ABOUT_RX_PAGES$")
bp.SetScriptCallbackFunction('{}.handle_new_rx_page'.format(__name__))
bp.SetAutoContinue(True)
print("-- LLDB integration loaded --")

View File

@@ -0,0 +1,5 @@
#
# Generated file, do not edit.
#
command script import --relative-to-command-file flutter_lldb_helper.py

View File

@@ -0,0 +1,13 @@
#!/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 "COCOAPODS_PARALLEL_CODE_SIGN=true"
export "FLUTTER_TARGET=lib/main.dart"
export "FLUTTER_BUILD_DIR=build"
export "FLUTTER_BUILD_NAME=1.0.0"
export "FLUTTER_BUILD_NUMBER=1"
export "DART_OBFUSCATION=false"
export "TRACK_WIDGET_CREATION=true"
export "TREE_SHAKE_ICONS=false"
export "PACKAGE_CONFIG=.dart_tool/package_config.json"

1
ios/Podfile Normal file
View File

@@ -0,0 +1 @@
platform :ios, '13.0'

View File

@@ -0,0 +1,19 @@
//
// Generated file. Do not edit.
//
// clang-format off
#ifndef GeneratedPluginRegistrant_h
#define GeneratedPluginRegistrant_h
#import <Flutter/Flutter.h>
NS_ASSUME_NONNULL_BEGIN
@interface GeneratedPluginRegistrant : NSObject
+ (void)registerWithRegistry:(NSObject<FlutterPluginRegistry>*)registry;
@end
NS_ASSUME_NONNULL_END
#endif /* GeneratedPluginRegistrant_h */

View File

@@ -0,0 +1,98 @@
//
// Generated file. Do not edit.
//
// clang-format off
#import "GeneratedPluginRegistrant.h"
#if __has_include(<audio_service/AudioServicePlugin.h>)
#import <audio_service/AudioServicePlugin.h>
#else
@import audio_service;
#endif
#if __has_include(<audio_session/AudioSessionPlugin.h>)
#import <audio_session/AudioSessionPlugin.h>
#else
@import audio_session;
#endif
#if __has_include(<flutter_compass/FlutterCompassPlugin.h>)
#import <flutter_compass/FlutterCompassPlugin.h>
#else
@import flutter_compass;
#endif
#if __has_include(<flutter_compass_v2/FlutterCompassPlugin.h>)
#import <flutter_compass_v2/FlutterCompassPlugin.h>
#else
@import flutter_compass_v2;
#endif
#if __has_include(<flutter_local_notifications/FlutterLocalNotificationsPlugin.h>)
#import <flutter_local_notifications/FlutterLocalNotificationsPlugin.h>
#else
@import flutter_local_notifications;
#endif
#if __has_include(<flutter_qiblah/FlutterQiblahPlugin.h>)
#import <flutter_qiblah/FlutterQiblahPlugin.h>
#else
@import flutter_qiblah;
#endif
#if __has_include(<geocoding_ios/GeocodingPlugin.h>)
#import <geocoding_ios/GeocodingPlugin.h>
#else
@import geocoding_ios;
#endif
#if __has_include(<geolocator_apple/GeolocatorPlugin.h>)
#import <geolocator_apple/GeolocatorPlugin.h>
#else
@import geolocator_apple;
#endif
#if __has_include(<just_audio/JustAudioPlugin.h>)
#import <just_audio/JustAudioPlugin.h>
#else
@import just_audio;
#endif
#if __has_include(<package_info_plus/FPPPackageInfoPlusPlugin.h>)
#import <package_info_plus/FPPPackageInfoPlusPlugin.h>
#else
@import package_info_plus;
#endif
#if __has_include(<sqflite_darwin/SqflitePlugin.h>)
#import <sqflite_darwin/SqflitePlugin.h>
#else
@import sqflite_darwin;
#endif
#if __has_include(<url_launcher_ios/URLLauncherPlugin.h>)
#import <url_launcher_ios/URLLauncherPlugin.h>
#else
@import url_launcher_ios;
#endif
@implementation GeneratedPluginRegistrant
+ (void)registerWithRegistry:(NSObject<FlutterPluginRegistry>*)registry {
[AudioServicePlugin registerWithRegistrar:[registry registrarForPlugin:@"AudioServicePlugin"]];
[AudioSessionPlugin registerWithRegistrar:[registry registrarForPlugin:@"AudioSessionPlugin"]];
[FlutterCompassPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterCompassPlugin"]];
[FlutterCompassPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterCompassPlugin"]];
[FlutterLocalNotificationsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterLocalNotificationsPlugin"]];
[FlutterQiblahPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterQiblahPlugin"]];
[GeocodingPlugin registerWithRegistrar:[registry registrarForPlugin:@"GeocodingPlugin"]];
[GeolocatorPlugin registerWithRegistrar:[registry registrarForPlugin:@"GeolocatorPlugin"]];
[JustAudioPlugin registerWithRegistrar:[registry registrarForPlugin:@"JustAudioPlugin"]];
[FPPPackageInfoPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPPackageInfoPlusPlugin"]];
[SqflitePlugin registerWithRegistrar:[registry registrarForPlugin:@"SqflitePlugin"]];
[URLLauncherPlugin registerWithRegistrar:[registry registrarForPlugin:@"URLLauncherPlugin"]];
}
@end

25
lib/app/app.dart Normal file
View File

@@ -0,0 +1,25 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/providers/theme_provider.dart';
import 'router.dart';
import 'theme/app_theme.dart';
/// Root MaterialApp.router wired to GoRouter + ThemeMode from Riverpod.
class App extends ConsumerWidget {
const App({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final themeMode = ref.watch(themeProvider);
return MaterialApp.router(
title: 'Jamshalat Diary',
debugShowCheckedModeBanner: false,
theme: AppTheme.light,
darkTheme: AppTheme.dark,
themeMode: themeMode,
routerConfig: appRouter,
);
}
}

175
lib/app/router.dart Normal file
View File

@@ -0,0 +1,175 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../core/widgets/bottom_nav_bar.dart';
import '../features/dashboard/presentation/dashboard_screen.dart';
import '../features/imsakiyah/presentation/imsakiyah_screen.dart';
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/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/settings/presentation/settings_screen.dart';
/// Navigation key for the shell navigator (bottom-nav screens).
final _rootNavigatorKey = GlobalKey<NavigatorState>();
final _shellNavigatorKey = GlobalKey<NavigatorState>();
/// GoRouter configuration per PRD §5.2.
final GoRouter appRouter = GoRouter(
navigatorKey: _rootNavigatorKey,
initialLocation: '/',
routes: [
// ── Shell route (bottom nav persists) ──
ShellRoute(
navigatorKey: _shellNavigatorKey,
builder: (context, state, child) => _ScaffoldWithNav(child: child),
routes: [
GoRoute(
path: '/',
pageBuilder: (context, state) => const NoTransitionPage(
child: DashboardScreen(),
),
routes: [
GoRoute(
path: 'qibla',
parentNavigatorKey: _rootNavigatorKey,
builder: (context, state) => const QiblaScreen(),
),
],
),
GoRoute(
path: '/imsakiyah',
pageBuilder: (context, state) => const NoTransitionPage(
child: ImsakiyahScreen(),
),
),
GoRoute(
path: '/checklist',
pageBuilder: (context, state) => const NoTransitionPage(
child: ChecklistScreen(),
),
),
GoRoute(
path: '/laporan',
pageBuilder: (context, state) => const NoTransitionPage(
child: LaporanScreen(),
),
),
GoRoute(
path: '/tools',
pageBuilder: (context, state) => const NoTransitionPage(
child: ToolsScreen(),
),
routes: [
GoRoute(
path: 'dzikir',
parentNavigatorKey: _rootNavigatorKey,
builder: (context, state) => const DzikirScreen(),
),
GoRoute(
path: 'quran',
parentNavigatorKey: _rootNavigatorKey,
builder: (context, state) => const QuranScreen(),
routes: [
GoRoute(
path: 'bookmarks',
parentNavigatorKey: _rootNavigatorKey,
builder: (context, state) => const QuranBookmarksScreen(),
),
GoRoute(
path: ':surahId',
parentNavigatorKey: _rootNavigatorKey,
builder: (context, state) {
final surahId = state.pathParameters['surahId']!;
final startVerse = int.tryParse(state.uri.queryParameters['startVerse'] ?? '');
return QuranReadingScreen(surahId: surahId, initialVerse: startVerse);
},
routes: [
GoRoute(
path: 'murattal',
parentNavigatorKey: _rootNavigatorKey,
builder: (context, state) {
final surahId = state.pathParameters['surahId']!;
final qariId = state.uri.queryParameters['qariId'];
final autoplay = state.uri.queryParameters['autoplay'] == 'true';
return QuranMurattalScreen(
surahId: surahId,
initialQariId: qariId,
autoPlay: autoplay,
);
},
),
],
),
],
),
GoRoute(
path: 'qibla',
parentNavigatorKey: _rootNavigatorKey,
builder: (context, state) => const QiblaScreen(),
),
],
),
],
),
// ── Settings (pushed, no bottom nav) ──
GoRoute(
path: '/settings',
parentNavigatorKey: _rootNavigatorKey,
builder: (context, state) => const SettingsScreen(),
),
],
);
/// Scaffold wrapper that provides the persistent bottom nav bar.
class _ScaffoldWithNav extends StatelessWidget {
const _ScaffoldWithNav({required this.child});
final Widget child;
/// Maps route locations to bottom nav indices.
int _currentIndex(BuildContext context) {
final location = GoRouterState.of(context).uri.toString();
if (location.startsWith('/imsakiyah')) return 1;
if (location.startsWith('/checklist')) return 2;
if (location.startsWith('/laporan')) return 3;
if (location.startsWith('/tools')) return 4;
return 0;
}
void _onTap(BuildContext context, int index) {
switch (index) {
case 0:
context.go('/');
break;
case 1:
context.go('/imsakiyah');
break;
case 2:
context.go('/checklist');
break;
case 3:
context.go('/laporan');
break;
case 4:
context.go('/tools');
break;
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: child,
bottomNavigationBar: AppBottomNavBar(
currentIndex: _currentIndex(context),
onTap: (i) => _onTap(context, i),
),
);
}
}

0
lib/app/theme/.gitkeep Normal file
View File

View File

@@ -0,0 +1,60 @@
import 'package:flutter/material.dart';
/// All color tokens from PRD §3.1 — light and dark values as static constants.
class AppColors {
AppColors._();
// ── Primary ──
static const Color primary = Color(0xFF70DF20);
static const Color onPrimary = Color(0xFF0A1A00);
// ── Background ──
static const Color backgroundLight = Color(0xFFF7F8F6);
static const Color backgroundDark = Color(0xFF182111);
// ── Surface ──
static const Color surfaceLight = Color(0xFFFFFFFF);
static const Color surfaceDark = Color(0xFF1E2A14);
// ── Sage (secondary text / section labels) ──
static const Color sage = Color(0xFF728764);
// ── Cream (dividers, borders — light mode only) ──
static const Color cream = Color(0xFFF2F4F0);
// ── 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);
// ── Semantic ──
static const Color errorLight = Color(0xFFEF4444);
static const Color errorDark = Color(0xFFF87171);
static const Color successLight = Color(0xFF22C55E);
static const Color successDark = Color(0xFF4ADE80);
// ── Convenience helpers for theme building ──
static ColorScheme get lightColorScheme => ColorScheme.light(
primary: primary,
onPrimary: onPrimary,
surface: surfaceLight,
onSurface: textPrimaryLight,
error: errorLight,
onError: Colors.white,
secondary: sage,
onSecondary: Colors.white,
);
static ColorScheme get darkColorScheme => ColorScheme.dark(
primary: primary,
onPrimary: onPrimary,
surface: surfaceDark,
onSurface: textPrimaryDark,
error: errorDark,
onError: Colors.black,
secondary: sage,
onSecondary: Colors.white,
);
}

View File

@@ -0,0 +1,70 @@
import 'package:flutter/material.dart';
/// Typography definitions from PRD §3.2.
/// Plus Jakarta Sans (bundled) for UI text, Amiri (bundled) for Arabic content.
class AppTextStyles {
AppTextStyles._();
static const String _fontFamily = 'PlusJakartaSans';
/// Builds the full TextTheme for the app using bundled Plus Jakarta Sans.
static const TextTheme textTheme = TextTheme(
displayLarge: TextStyle(
fontFamily: _fontFamily,
fontSize: 32,
fontWeight: FontWeight.w800,
),
headlineMedium: TextStyle(
fontFamily: _fontFamily,
fontSize: 24,
fontWeight: FontWeight.w700,
),
titleLarge: TextStyle(
fontFamily: _fontFamily,
fontSize: 20,
fontWeight: FontWeight.w700,
),
titleMedium: TextStyle(
fontFamily: _fontFamily,
fontSize: 16,
fontWeight: FontWeight.w600,
),
bodyLarge: TextStyle(
fontFamily: _fontFamily,
fontSize: 16,
fontWeight: FontWeight.w400,
),
bodyMedium: TextStyle(
fontFamily: _fontFamily,
fontSize: 14,
fontWeight: FontWeight.w400,
),
bodySmall: TextStyle(
fontFamily: _fontFamily,
fontSize: 12,
fontWeight: FontWeight.w400,
),
labelSmall: TextStyle(
fontFamily: _fontFamily,
fontSize: 10,
fontWeight: FontWeight.w700,
letterSpacing: 1.5,
),
);
// ── Arabic text styles (Amiri — bundled font) ──
static const TextStyle arabicBody = TextStyle(
fontFamily: 'Amiri',
fontSize: 24,
fontWeight: FontWeight.w400,
height: 2.0,
);
static const TextStyle arabicLarge = TextStyle(
fontFamily: 'Amiri',
fontSize: 28,
fontWeight: FontWeight.w700,
height: 2.2,
);
}

View File

@@ -0,0 +1,90 @@
import 'package:flutter/material.dart';
import 'app_colors.dart';
import 'app_text_styles.dart';
/// ThemeData for light and dark modes, Material 3 enabled.
class AppTheme {
AppTheme._();
static ThemeData get light => ThemeData(
useMaterial3: true,
brightness: Brightness.light,
colorScheme: AppColors.lightColorScheme,
scaffoldBackgroundColor: AppColors.backgroundLight,
textTheme: AppTextStyles.textTheme.apply(
bodyColor: AppColors.textPrimaryLight,
displayColor: AppColors.textPrimaryLight,
),
appBarTheme: const AppBarTheme(
backgroundColor: Colors.transparent,
elevation: 0,
scrolledUnderElevation: 0,
centerTitle: true,
iconTheme: IconThemeData(color: AppColors.textPrimaryLight),
titleTextStyle: TextStyle(
color: AppColors.textPrimaryLight,
fontSize: 20,
fontWeight: FontWeight.w700,
),
),
bottomNavigationBarTheme: const BottomNavigationBarThemeData(
backgroundColor: AppColors.surfaceLight,
selectedItemColor: AppColors.primary,
unselectedItemColor: AppColors.textSecondaryLight,
type: BottomNavigationBarType.fixed,
elevation: 0,
),
cardTheme: CardThemeData(
color: AppColors.surfaceLight,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: AppColors.cream,
),
),
),
dividerColor: AppColors.cream,
);
static ThemeData get dark => ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
colorScheme: AppColors.darkColorScheme,
scaffoldBackgroundColor: AppColors.backgroundDark,
textTheme: AppTextStyles.textTheme.apply(
bodyColor: AppColors.textPrimaryDark,
displayColor: AppColors.textPrimaryDark,
),
appBarTheme: const AppBarTheme(
backgroundColor: Colors.transparent,
elevation: 0,
scrolledUnderElevation: 0,
centerTitle: true,
iconTheme: IconThemeData(color: AppColors.textPrimaryDark),
titleTextStyle: TextStyle(
color: AppColors.textPrimaryDark,
fontSize: 20,
fontWeight: FontWeight.w700,
),
),
bottomNavigationBarTheme: const BottomNavigationBarThemeData(
backgroundColor: AppColors.surfaceDark,
selectedItemColor: AppColors.primary,
unselectedItemColor: AppColors.textSecondaryDark,
type: BottomNavigationBarType.fixed,
elevation: 0,
),
cardTheme: CardThemeData(
color: AppColors.surfaceDark,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: AppColors.primary.withValues(alpha: 0.1),
),
),
),
dividerColor: AppColors.surfaceDark,
);
}

4
lib/app/theme/theme.dart Normal file
View File

@@ -0,0 +1,4 @@
// Barrel file for theme exports.
export 'app_colors.dart';
export 'app_text_styles.dart';
export 'app_theme.dart';

View File

View File

@@ -0,0 +1 @@
// TODO: implement

View File

@@ -0,0 +1,12 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:hive_flutter/hive_flutter.dart';
import '../../data/local/hive_boxes.dart';
import '../../data/local/models/app_settings.dart';
/// Theme mode state provider.
final themeProvider = StateProvider<ThemeMode>((ref) {
final box = Hive.box<AppSettings>(HiveBoxes.settings);
final settings = box.get('default');
return settings?.themeModeIndex == 1 ? ThemeMode.light : ThemeMode.dark;
});

View File

@@ -0,0 +1,54 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// Models an active reading session for Tilawah
class TilawahSession {
final int startSurahId;
final String startSurahName;
final int startVerseId;
const TilawahSession({
required this.startSurahId,
required this.startSurahName,
required this.startVerseId,
});
TilawahSession copyWith({
int? startSurahId,
String? startSurahName,
int? startVerseId,
}) {
return TilawahSession(
startSurahId: startSurahId ?? this.startSurahId,
startSurahName: startSurahName ?? this.startSurahName,
startVerseId: startVerseId ?? this.startVerseId,
);
}
}
/// A state notifier to manage the global start state of a reading session.
/// If state is null, no active tracking is occurring.
class TilawahTrackingNotifier extends StateNotifier<TilawahSession?> {
TilawahTrackingNotifier() : super(null);
/// Start a new tracking session
void startTracking({
required int surahId,
required String surahName,
required int verseId
}) {
state = TilawahSession(
startSurahId: surahId,
startSurahName: surahName,
startVerseId: verseId,
);
}
/// Stop tracking (after recording)
void stopTracking() {
state = null;
}
}
final tilawahTrackingProvider = StateNotifierProvider<TilawahTrackingNotifier, TilawahSession?>((ref) {
return TilawahTrackingNotifier();
});

0
lib/core/utils/.gitkeep Normal file
View File

View File

@@ -0,0 +1 @@
// TODO: implement

View File

View File

@@ -0,0 +1,66 @@
import 'package:flutter/material.dart';
import '../../app/theme/app_colors.dart';
/// 5-tab bottom navigation bar per PRD §5.1.
/// Uses Material Symbols outlined (inactive) and filled (active).
class AppBottomNavBar extends StatelessWidget {
const AppBottomNavBar({
super.key,
required this.currentIndex,
required this.onTap,
});
final int currentIndex;
final ValueChanged<int> onTap;
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: Theme.of(context).bottomNavigationBarTheme.backgroundColor,
border: Border(
top: BorderSide(
color: Theme.of(context).dividerColor,
width: 0.5,
),
),
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.only(bottom: 8),
child: BottomNavigationBar(
currentIndex: currentIndex,
onTap: onTap,
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.home_outlined),
activeIcon: Icon(Icons.home),
label: 'Beranda',
),
BottomNavigationBarItem(
icon: Icon(Icons.calendar_today_outlined),
activeIcon: Icon(Icons.calendar_today),
label: 'Jadwal',
),
BottomNavigationBarItem(
icon: Icon(Icons.rule_outlined),
activeIcon: Icon(Icons.rule),
label: 'Ibadah',
),
BottomNavigationBarItem(
icon: Icon(Icons.bar_chart_outlined),
activeIcon: Icon(Icons.bar_chart),
label: 'Laporan',
),
BottomNavigationBarItem(
icon: Icon(Icons.auto_fix_high_outlined),
activeIcon: Icon(Icons.auto_fix_high),
label: 'Alat',
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,58 @@
import 'package:flutter/material.dart';
import '../../app/theme/app_colors.dart';
/// Custom iOS-style toggle switch (51×31dp) per PRD §6.9.
/// Uses AnimatedContainer + GestureDetector for smooth animation.
class IosToggle extends StatelessWidget {
const IosToggle({
super.key,
required this.value,
required this.onChanged,
});
final bool value;
final ValueChanged<bool> onChanged;
static const double _width = 51.0;
static const double _height = 31.0;
static const double _thumbSize = 27.0;
static const double _thumbPadding = 2.0;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => onChanged(!value),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut,
width: _width,
height: _height,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(_height / 2),
color: value ? AppColors.primary : AppColors.cream,
),
child: AnimatedAlign(
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut,
alignment: value ? Alignment.centerRight : Alignment.centerLeft,
child: Container(
width: _thumbSize,
height: _thumbSize,
margin: const EdgeInsets.all(_thumbPadding),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.15),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
),
),
),
);
}
}

View File

@@ -0,0 +1 @@
// TODO: implement

View File

@@ -0,0 +1,68 @@
import 'package:flutter/material.dart';
import '../../app/theme/app_colors.dart';
/// Reusable prayer time card widget for the horizontal scroll on Dashboard.
/// Will be fully implemented in Phase 3.
class PrayerTimeCard extends StatelessWidget {
const PrayerTimeCard({
super.key,
required this.prayerName,
required this.time,
required this.icon,
this.isActive = false,
});
final String prayerName;
final String time;
final IconData icon;
final bool isActive;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
width: 112,
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 12),
decoration: BoxDecoration(
color: isActive
? AppColors.primary.withValues(alpha: 0.1)
: theme.cardTheme.color,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: isActive
? AppColors.primary
: AppColors.primary.withValues(alpha: 0.1),
width: isActive ? 2 : 1,
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: 24,
color: isActive ? AppColors.primary : AppColors.sage,
),
const SizedBox(height: 8),
Text(
prayerName,
style: theme.textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.bold,
color: isActive ? AppColors.primary : null,
),
),
const SizedBox(height: 4),
Text(
time,
style: theme.textTheme.bodySmall?.copyWith(
color: isActive
? AppColors.primary
: theme.textTheme.bodySmall?.color,
),
),
],
),
);
}
}

View File

@@ -0,0 +1,69 @@
import 'package:flutter/material.dart';
import '../../app/theme/app_colors.dart';
/// Reusable linear progress bar with primary fill.
/// Configurable height, borderRadius, and value (0.01.0).
class AppProgressBar extends StatelessWidget {
const AppProgressBar({
super.key,
required this.value,
this.height = 12.0,
this.borderRadius,
this.backgroundColor,
this.fillColor,
});
/// Progress value from 0.0 to 1.0.
final double value;
/// Height of the bar. Default 12dp.
final double height;
/// Border radius. Defaults to stadium (full).
final BorderRadius? borderRadius;
/// Background track color. Defaults to white/10 (dark) or primary/10 (light).
final Color? backgroundColor;
/// Fill color. Defaults to AppColors.primary.
final Color? fillColor;
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final trackColor = backgroundColor ??
(isDark
? Colors.white.withValues(alpha: 0.1)
: AppColors.primary.withValues(alpha: 0.1));
final fill = fillColor ?? AppColors.primary;
final radius = borderRadius ?? BorderRadius.circular(height / 2);
return ClipRRect(
borderRadius: radius,
child: SizedBox(
height: height,
child: Stack(
children: [
// Track
Container(
decoration: BoxDecoration(
color: trackColor,
borderRadius: radius,
),
),
// Fill
FractionallySizedBox(
widthFactor: value.clamp(0.0, 1.0),
child: Container(
decoration: BoxDecoration(
color: fill,
borderRadius: radius,
),
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,35 @@
import 'package:flutter/material.dart';
import '../../app/theme/app_colors.dart';
/// Reusable uppercase section label (e.g. "NOTIFICATIONS", "DISPLAY").
/// Uses sage color, tracking-wider, bold weight per PRD §3.2 labelSmall.
class SectionHeader extends StatelessWidget {
const SectionHeader({
super.key,
required this.title,
this.trailing,
});
final String title;
final Widget? trailing;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
title.toUpperCase(),
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: AppColors.sage,
letterSpacing: 1.5,
),
),
if (trailing != null) trailing!,
],
),
);
}
}

View File

View File

@@ -0,0 +1 @@
// TODO: implement

View File

@@ -0,0 +1,119 @@
import 'package:flutter/foundation.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'models/app_settings.dart';
import 'models/checklist_item.dart';
import 'models/daily_worship_log.dart';
import 'models/dzikir_counter.dart';
import 'models/quran_bookmark.dart';
import 'models/cached_prayer_times.dart';
import 'models/shalat_log.dart';
import 'models/tilawah_log.dart';
import 'models/dzikir_log.dart';
import 'models/puasa_log.dart';
/// Box name constants for Hive.
class HiveBoxes {
HiveBoxes._();
static const String settings = 'settings';
static const String checklistItems = 'checklist_items';
static const String worshipLogs = 'worship_logs';
static const String dzikirCounters = 'dzikir_counters';
static const String bookmarks = 'bookmarks';
static const String cachedPrayerTimes = 'cached_prayer_times';
}
/// Initialize Hive and open all boxes.
Future<void> initHive() async {
await Hive.initFlutter();
// Register adapters
Hive.registerAdapter(AppSettingsAdapter());
Hive.registerAdapter(ChecklistItemAdapter());
Hive.registerAdapter(DailyWorshipLogAdapter());
Hive.registerAdapter(DzikirCounterAdapter());
Hive.registerAdapter(QuranBookmarkAdapter());
Hive.registerAdapter(CachedPrayerTimesAdapter());
Hive.registerAdapter(ShalatLogAdapter());
Hive.registerAdapter(TilawahLogAdapter());
Hive.registerAdapter(DzikirLogAdapter());
Hive.registerAdapter(PuasaLogAdapter());
// Open boxes
try {
await Hive.openBox<AppSettings>(HiveBoxes.settings);
} catch (e) {
debugPrint('Settings box corrupted, resetting: $e');
if (Hive.isBoxOpen(HiveBoxes.settings)) {
await Hive.box<AppSettings>(HiveBoxes.settings).close();
}
await Hive.deleteBoxFromDisk(HiveBoxes.settings);
await Hive.openBox<AppSettings>(HiveBoxes.settings);
}
await Hive.openBox<ChecklistItem>(HiveBoxes.checklistItems);
final worshipBox = await Hive.openBox<DailyWorshipLog>(HiveBoxes.worshipLogs);
await Hive.openBox<DzikirCounter>(HiveBoxes.dzikirCounters);
await Hive.openBox<QuranBookmark>(HiveBoxes.bookmarks);
await Hive.openBox<CachedPrayerTimes>(HiveBoxes.cachedPrayerTimes);
// MIGRATION: Delete legacy logs that crash due to type casts (Map<String, bool> vs Map<String, ShalatLog>)
final keysToDelete = [];
for (final key in worshipBox.keys) {
try {
final log = worshipBox.get(key);
if (log != null) {
log.shalatLogs.values.toList(); // Force evaluation
}
} catch (_) {
keysToDelete.add(key);
}
}
if (keysToDelete.isNotEmpty) {
await worshipBox.deleteAll(keysToDelete);
debugPrint('Deleted ${keysToDelete.length} legacy worship logs.');
}
}
/// Seeds default settings and checklist items on first launch.
Future<void> seedDefaults() async {
// Seed AppSettings
final settingsBox = Hive.box<AppSettings>(HiveBoxes.settings);
if (settingsBox.isEmpty) {
await settingsBox.put('default', AppSettings());
}
// Seed default checklist items
final checklistBox = Hive.box<ChecklistItem>(HiveBoxes.checklistItems);
if (checklistBox.isEmpty) {
final defaults = [
ChecklistItem(
id: 'fajr', title: 'Sholat Fajr', category: 'sholat_fardhu', sortOrder: 0),
ChecklistItem(
id: 'dhuhr', title: 'Sholat Dhuhr', category: 'sholat_fardhu', sortOrder: 1),
ChecklistItem(
id: 'asr', title: 'Sholat Asr', category: 'sholat_fardhu', sortOrder: 2),
ChecklistItem(
id: 'maghrib', title: 'Sholat Maghrib', category: 'sholat_fardhu', sortOrder: 3),
ChecklistItem(
id: 'isha', title: 'Sholat Isha', category: 'sholat_fardhu', sortOrder: 4),
ChecklistItem(
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),
ChecklistItem(
id: 'dzikir_petang', title: 'Dzikir Petang', category: 'dzikir',
subtitle: '1 session', sortOrder: 7),
ChecklistItem(
id: 'rawatib', title: 'Sholat Sunnah Rawatib', category: 'sunnah', sortOrder: 8),
ChecklistItem(
id: 'shodaqoh', title: 'Shodaqoh', category: 'charity', sortOrder: 9),
];
for (final item in defaults) {
await checklistBox.put(item.id, item);
}
}
}

View File

@@ -0,0 +1,101 @@
import 'package:hive_flutter/hive_flutter.dart';
part 'app_settings.g.dart';
/// User settings stored in Hive.
@HiveType(typeId: 0)
class AppSettings extends HiveObject {
@HiveField(0)
String userName;
@HiveField(1)
String userEmail;
@HiveField(2)
int themeModeIndex; // 0=system, 1=light, 2=dark
@HiveField(3)
double arabicFontSize;
@HiveField(4)
String uiLanguage; // 'id' or 'en'
@HiveField(5)
Map<String, bool> adhanEnabled;
@HiveField(6)
Map<String, int> iqamahOffset; // minutes
@HiveField(7)
String? checklistReminderTime; // HH:mm format
@HiveField(8)
double? lastLat;
@HiveField(9)
double? lastLng;
@HiveField(10)
String? lastCityName;
@HiveField(11)
int rawatibLevel; // 0 = Off, 1 = Muakkad Only, 2 = Full
@HiveField(12)
int tilawahTargetValue;
@HiveField(13)
String tilawahTargetUnit; // 'Juz', 'Halaman', 'Ayat'
@HiveField(14)
bool tilawahAutoSync;
@HiveField(15)
bool trackDzikir;
@HiveField(16)
bool trackPuasa;
@HiveField(17)
bool showLatin;
@HiveField(18)
bool showTerjemahan;
AppSettings({
this.userName = 'User',
this.userEmail = '',
this.themeModeIndex = 0,
this.arabicFontSize = 24.0,
this.uiLanguage = 'id',
Map<String, bool>? adhanEnabled,
Map<String, int>? iqamahOffset,
this.checklistReminderTime = '09:00',
this.lastLat,
this.lastLng,
this.lastCityName,
this.rawatibLevel = 1, // Default to Muakkad
this.tilawahTargetValue = 1,
this.tilawahTargetUnit = 'Juz',
this.tilawahAutoSync = false,
this.trackDzikir = true,
this.trackPuasa = false,
this.showLatin = true,
this.showTerjemahan = true,
}) : adhanEnabled = adhanEnabled ??
{
'fajr': true,
'dhuhr': true,
'asr': true,
'maghrib': true,
'isha': true,
},
iqamahOffset = iqamahOffset ??
{
'fajr': 15,
'dhuhr': 10,
'asr': 10,
'maghrib': 5,
'isha': 10,
};
}

View File

@@ -0,0 +1,95 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'app_settings.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class AppSettingsAdapter extends TypeAdapter<AppSettings> {
@override
final int typeId = 0;
@override
AppSettings read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return 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,
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,
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,
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,
);
}
@override
void write(BinaryWriter writer, AppSettings obj) {
writer
..writeByte(19)
..writeByte(0)
..write(obj.userName)
..writeByte(1)
..write(obj.userEmail)
..writeByte(2)
..write(obj.themeModeIndex)
..writeByte(3)
..write(obj.arabicFontSize)
..writeByte(4)
..write(obj.uiLanguage)
..writeByte(5)
..write(obj.adhanEnabled)
..writeByte(6)
..write(obj.iqamahOffset)
..writeByte(7)
..write(obj.checklistReminderTime)
..writeByte(8)
..write(obj.lastLat)
..writeByte(9)
..write(obj.lastLng)
..writeByte(10)
..write(obj.lastCityName)
..writeByte(11)
..write(obj.rawatibLevel)
..writeByte(12)
..write(obj.tilawahTargetValue)
..writeByte(13)
..write(obj.tilawahTargetUnit)
..writeByte(14)
..write(obj.tilawahAutoSync)
..writeByte(15)
..write(obj.trackDzikir)
..writeByte(16)
..write(obj.trackPuasa)
..writeByte(17)
..write(obj.showLatin)
..writeByte(18)
..write(obj.showTerjemahan);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is AppSettingsAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -0,0 +1,50 @@
import 'package:hive_flutter/hive_flutter.dart';
part 'cached_prayer_times.g.dart';
/// Cached prayer times for a specific location + date.
@HiveType(typeId: 5)
class CachedPrayerTimes extends HiveObject {
@HiveField(0)
String key; // 'lat_lng_yyyy-MM-dd'
@HiveField(1)
double lat;
@HiveField(2)
double lng;
@HiveField(3)
String date;
@HiveField(4)
DateTime fajr;
@HiveField(5)
DateTime sunrise;
@HiveField(6)
DateTime dhuhr;
@HiveField(7)
DateTime asr;
@HiveField(8)
DateTime maghrib;
@HiveField(9)
DateTime isha;
CachedPrayerTimes({
required this.key,
required this.lat,
required this.lng,
required this.date,
required this.fajr,
required this.sunrise,
required this.dhuhr,
required this.asr,
required this.maghrib,
required this.isha,
});
}

View File

@@ -0,0 +1,68 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'cached_prayer_times.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class CachedPrayerTimesAdapter extends TypeAdapter<CachedPrayerTimes> {
@override
final int typeId = 5;
@override
CachedPrayerTimes read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return CachedPrayerTimes(
key: fields[0] as String,
lat: fields[1] as double,
lng: fields[2] as double,
date: fields[3] as String,
fajr: fields[4] as DateTime,
sunrise: fields[5] as DateTime,
dhuhr: fields[6] as DateTime,
asr: fields[7] as DateTime,
maghrib: fields[8] as DateTime,
isha: fields[9] as DateTime,
);
}
@override
void write(BinaryWriter writer, CachedPrayerTimes obj) {
writer
..writeByte(10)
..writeByte(0)
..write(obj.key)
..writeByte(1)
..write(obj.lat)
..writeByte(2)
..write(obj.lng)
..writeByte(3)
..write(obj.date)
..writeByte(4)
..write(obj.fajr)
..writeByte(5)
..write(obj.sunrise)
..writeByte(6)
..write(obj.dhuhr)
..writeByte(7)
..write(obj.asr)
..writeByte(8)
..write(obj.maghrib)
..writeByte(9)
..write(obj.isha);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is CachedPrayerTimesAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -0,0 +1,34 @@
import 'package:hive_flutter/hive_flutter.dart';
part 'checklist_item.g.dart';
/// A single checklist item definition (template, not daily state).
@HiveType(typeId: 1)
class ChecklistItem extends HiveObject {
@HiveField(0)
String id;
@HiveField(1)
String title;
@HiveField(2)
String category;
@HiveField(3)
String? subtitle;
@HiveField(4)
int sortOrder;
@HiveField(5)
bool isCustom;
ChecklistItem({
required this.id,
required this.title,
required this.category,
this.subtitle,
required this.sortOrder,
this.isCustom = false,
});
}

View File

@@ -0,0 +1,56 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'checklist_item.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class ChecklistItemAdapter extends TypeAdapter<ChecklistItem> {
@override
final int typeId = 1;
@override
ChecklistItem read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return ChecklistItem(
id: fields[0] as String,
title: fields[1] as String,
category: fields[2] as String,
subtitle: fields[3] as String?,
sortOrder: fields[4] as int,
isCustom: fields[5] as bool,
);
}
@override
void write(BinaryWriter writer, ChecklistItem obj) {
writer
..writeByte(6)
..writeByte(0)
..write(obj.id)
..writeByte(1)
..write(obj.title)
..writeByte(2)
..write(obj.category)
..writeByte(3)
..write(obj.subtitle)
..writeByte(4)
..write(obj.sortOrder)
..writeByte(5)
..write(obj.isCustom);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ChecklistItemAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -0,0 +1,86 @@
import 'package:hive_flutter/hive_flutter.dart';
import 'shalat_log.dart';
import 'tilawah_log.dart';
import 'dzikir_log.dart';
import 'puasa_log.dart';
part 'daily_worship_log.g.dart';
/// Daily worship completion log, keyed by date string 'yyyy-MM-dd'.
@HiveType(typeId: 2)
class DailyWorshipLog extends HiveObject {
@HiveField(0)
String date;
@HiveField(1)
Map<String, ShalatLog> shalatLogs; // e.g., 'subuh' -> ShalatLog
@HiveField(5)
TilawahLog? tilawahLog;
@HiveField(6)
DzikirLog? dzikirLog;
@HiveField(7)
PuasaLog? puasaLog;
@HiveField(2)
int totalItems;
@HiveField(3)
int completedCount;
@HiveField(4)
double completionPercent;
DailyWorshipLog({
required this.date,
Map<String, ShalatLog>? shalatLogs,
this.tilawahLog,
this.dzikirLog,
this.puasaLog,
this.totalItems = 0,
this.completedCount = 0,
this.completionPercent = 0.0,
}) : shalatLogs = shalatLogs ?? {};
/// Dynamically calculates the "Poin Ibadah" for this day.
int get totalPoints {
int points = 0;
// 1. Shalat Fardhu
for (final sLog in shalatLogs.values) {
if (sLog.completed) {
if (sLog.location == 'Masjid') {
points += 25;
} else {
points += 10;
}
}
if (sLog.qabliyah == true) points += 5;
if (sLog.badiyah == true) points += 5;
}
// 2. Tilawah
if (tilawahLog != null) {
// 1 point per Ayat read
points += tilawahLog!.rawAyatRead;
// Bonus 20 points for completing daily target
if (tilawahLog!.isCompleted) {
points += 20;
}
}
// 3. Dzikir & Puasa
if (dzikirLog != null) {
if (dzikirLog!.pagi) points += 10;
if (dzikirLog!.petang) points += 10;
}
if (puasaLog != null && puasaLog!.completed) {
points += 30;
}
return points;
}
}

View File

@@ -0,0 +1,70 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'daily_worship_log.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class DailyWorshipLogAdapter extends TypeAdapter<DailyWorshipLog> {
@override
final int typeId = 2;
@override
DailyWorshipLog read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
Map<String, ShalatLog>? parsedShalatLogs;
try {
parsedShalatLogs = (fields[1] as Map?)?.cast<String, ShalatLog>();
} catch (_) {
// If casting fails (e.g. it was the old Map<String, bool>), ignore it.
parsedShalatLogs = {};
}
return DailyWorshipLog(
date: fields[0] as String,
shalatLogs: parsedShalatLogs,
tilawahLog: fields[5] as TilawahLog?,
dzikirLog: fields[6] as DzikirLog?,
puasaLog: fields[7] as PuasaLog?,
totalItems: fields[2] as int,
completedCount: fields[3] as int,
completionPercent: fields[4] as double,
);
}
@override
void write(BinaryWriter writer, DailyWorshipLog obj) {
writer
..writeByte(8)
..writeByte(0)
..write(obj.date)
..writeByte(1)
..write(obj.shalatLogs)
..writeByte(5)
..write(obj.tilawahLog)
..writeByte(6)
..write(obj.dzikirLog)
..writeByte(7)
..write(obj.puasaLog)
..writeByte(2)
..write(obj.totalItems)
..writeByte(3)
..write(obj.completedCount)
..writeByte(4)
..write(obj.completionPercent);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is DailyWorshipLogAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -0,0 +1,26 @@
import 'package:hive_flutter/hive_flutter.dart';
part 'dzikir_counter.g.dart';
/// Counter for a single dzikir item on a specific date.
@HiveType(typeId: 3)
class DzikirCounter extends HiveObject {
@HiveField(0)
String dzikirId;
@HiveField(1)
String date;
@HiveField(2)
int count;
@HiveField(3)
int target;
DzikirCounter({
required this.dzikirId,
required this.date,
this.count = 0,
required this.target,
});
}

View File

@@ -0,0 +1,50 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'dzikir_counter.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class DzikirCounterAdapter extends TypeAdapter<DzikirCounter> {
@override
final int typeId = 3;
@override
DzikirCounter read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return DzikirCounter(
dzikirId: fields[0] as String,
date: fields[1] as String,
count: fields[2] as int,
target: fields[3] as int,
);
}
@override
void write(BinaryWriter writer, DzikirCounter obj) {
writer
..writeByte(4)
..writeByte(0)
..write(obj.dzikirId)
..writeByte(1)
..write(obj.date)
..writeByte(2)
..write(obj.count)
..writeByte(3)
..write(obj.target);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is DzikirCounterAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -0,0 +1,17 @@
import 'package:hive/hive.dart';
part 'dzikir_log.g.dart';
@HiveType(typeId: 9)
class DzikirLog {
@HiveField(0)
bool pagi;
@HiveField(1)
bool petang;
DzikirLog({
this.pagi = false,
this.petang = false,
});
}

View File

@@ -0,0 +1,44 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'dzikir_log.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class DzikirLogAdapter extends TypeAdapter<DzikirLog> {
@override
final int typeId = 9;
@override
DzikirLog read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return DzikirLog(
pagi: fields[0] as bool,
petang: fields[1] as bool,
);
}
@override
void write(BinaryWriter writer, DzikirLog obj) {
writer
..writeByte(2)
..writeByte(0)
..write(obj.pagi)
..writeByte(1)
..write(obj.petang);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is DzikirLogAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -0,0 +1,17 @@
import 'package:hive/hive.dart';
part 'puasa_log.g.dart';
@HiveType(typeId: 10)
class PuasaLog {
@HiveField(0)
String? jenisPuasa; // 'Senin', 'Kamis', 'Ayyamul Bidh', 'Daud', etc.
@HiveField(1)
bool completed;
PuasaLog({
this.jenisPuasa,
this.completed = false,
});
}

View File

@@ -0,0 +1,44 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'puasa_log.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class PuasaLogAdapter extends TypeAdapter<PuasaLog> {
@override
final int typeId = 10;
@override
PuasaLog read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return PuasaLog(
jenisPuasa: fields[0] as String?,
completed: fields[1] as bool,
);
}
@override
void write(BinaryWriter writer, PuasaLog obj) {
writer
..writeByte(2)
..writeByte(0)
..write(obj.jenisPuasa)
..writeByte(1)
..write(obj.completed);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is PuasaLogAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -0,0 +1,42 @@
import 'package:hive_flutter/hive_flutter.dart';
part 'quran_bookmark.g.dart';
/// A bookmarked Quran verse.
@HiveType(typeId: 4)
class QuranBookmark extends HiveObject {
@HiveField(0)
int surahId;
@HiveField(1)
int verseId;
@HiveField(2)
String surahName;
@HiveField(3)
String verseText; // Arabic snippet
@HiveField(4)
DateTime savedAt;
@HiveField(5)
bool isLastRead;
@HiveField(6)
String? verseLatin;
@HiveField(7)
String? verseTranslation;
QuranBookmark({
required this.surahId,
required this.verseId,
required this.surahName,
required this.verseText,
required this.savedAt,
this.isLastRead = false,
this.verseLatin,
this.verseTranslation,
});
}

View File

@@ -0,0 +1,62 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'quran_bookmark.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class QuranBookmarkAdapter extends TypeAdapter<QuranBookmark> {
@override
final int typeId = 4;
@override
QuranBookmark read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return QuranBookmark(
surahId: fields[0] as int,
verseId: fields[1] as int,
surahName: fields[2] as String,
verseText: fields[3] as String,
savedAt: fields[4] as DateTime,
isLastRead: fields[5] as bool? ?? false,
verseLatin: fields[6] as String?,
verseTranslation: fields[7] as String?,
);
}
@override
void write(BinaryWriter writer, QuranBookmark obj) {
writer
..writeByte(8)
..writeByte(0)
..write(obj.surahId)
..writeByte(1)
..write(obj.verseId)
..writeByte(2)
..write(obj.surahName)
..writeByte(3)
..write(obj.verseText)
..writeByte(4)
..write(obj.savedAt)
..writeByte(5)
..write(obj.isLastRead)
..writeByte(6)
..write(obj.verseLatin)
..writeByte(7)
..write(obj.verseTranslation);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is QuranBookmarkAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -0,0 +1,25 @@
import 'package:hive/hive.dart';
part 'shalat_log.g.dart';
@HiveType(typeId: 7)
class ShalatLog {
@HiveField(0)
bool completed;
@HiveField(1)
String? location; // 'Rumah' or 'Masjid'
@HiveField(2)
bool? qabliyah;
@HiveField(3)
bool? badiyah;
ShalatLog({
this.completed = false,
this.location,
this.qabliyah,
this.badiyah,
});
}

View File

@@ -0,0 +1,50 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'shalat_log.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class ShalatLogAdapter extends TypeAdapter<ShalatLog> {
@override
final int typeId = 7;
@override
ShalatLog read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return ShalatLog(
completed: fields[0] as bool,
location: fields[1] as String?,
qabliyah: fields[2] as bool?,
badiyah: fields[3] as bool?,
);
}
@override
void write(BinaryWriter writer, ShalatLog obj) {
writer
..writeByte(4)
..writeByte(0)
..write(obj.completed)
..writeByte(1)
..write(obj.location)
..writeByte(2)
..write(obj.qabliyah)
..writeByte(3)
..write(obj.badiyah);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ShalatLogAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -0,0 +1,35 @@
import 'package:hive/hive.dart';
part 'tilawah_log.g.dart';
@HiveType(typeId: 8)
class TilawahLog {
@HiveField(0)
int targetValue;
@HiveField(1)
String targetUnit; // 'Juz', 'Halaman', 'Ayat'
@HiveField(2)
int currentProgress;
@HiveField(3)
bool autoSync;
@HiveField(4)
int rawAyatRead;
@HiveField(5)
bool targetCompleted;
TilawahLog({
this.targetValue = 1,
this.targetUnit = 'Juz',
this.currentProgress = 0,
this.autoSync = false,
this.rawAyatRead = 0,
this.targetCompleted = false,
});
bool get isCompleted => targetCompleted;
}

View File

@@ -0,0 +1,56 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'tilawah_log.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class TilawahLogAdapter extends TypeAdapter<TilawahLog> {
@override
final int typeId = 8;
@override
TilawahLog read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return TilawahLog(
targetValue: fields[0] as int,
targetUnit: fields[1] as String,
currentProgress: fields[2] as int,
autoSync: fields[3] as bool,
rawAyatRead: fields[4] as int? ?? 0,
targetCompleted: fields[5] as bool? ?? false,
);
}
@override
void write(BinaryWriter writer, TilawahLog obj) {
writer
..writeByte(6)
..writeByte(0)
..write(obj.targetValue)
..writeByte(1)
..write(obj.targetUnit)
..writeByte(2)
..write(obj.currentProgress)
..writeByte(3)
..write(obj.autoSync)
..writeByte(4)
..write(obj.rawAyatRead)
..writeByte(5)
..write(obj.targetCompleted);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is TilawahLogAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

View File

@@ -0,0 +1,107 @@
import 'dart:convert';
import 'package:flutter/services.dart';
import 'package:hive_flutter/hive_flutter.dart';
import '../local/hive_boxes.dart';
import '../local/models/dzikir_counter.dart';
import 'package:intl/intl.dart';
/// Represents a single dzikir item from the bundled JSON.
class DzikirItem {
final String id;
final String arabic;
final String transliteration;
final String translation;
final int targetCount;
final String? source;
DzikirItem({
required this.id,
required this.arabic,
required this.transliteration,
required this.translation,
required this.targetCount,
this.source,
});
factory DzikirItem.fromJson(Map<String, dynamic> json) {
return DzikirItem(
id: json['id'] as String,
arabic: json['arabic'] as String? ?? '',
transliteration: json['transliteration'] as String? ?? '',
translation: json['translation'] as String? ?? '',
targetCount: json['target_count'] as int? ?? 1,
source: json['source'] as String?,
);
}
}
/// Types of dzikir sessions.
enum DzikirType { pagi, petang }
/// Service to load dzikir data and manage counters.
class DzikirService {
DzikirService._();
static final DzikirService instance = DzikirService._();
final Map<DzikirType, List<DzikirItem>> _cache = {};
/// Load dzikir items from bundled JSON.
Future<List<DzikirItem>> getDzikir(DzikirType type) async {
if (_cache.containsKey(type)) return _cache[type]!;
final path = type == DzikirType.pagi
? 'assets/dzikir/dzikir_pagi.json'
: 'assets/dzikir/dzikir_petang.json';
try {
final jsonString = await rootBundle.loadString(path);
final List<dynamic> data = json.decode(jsonString);
_cache[type] =
data.map((d) => DzikirItem.fromJson(d as Map<String, dynamic>)).toList();
} catch (_) {
_cache[type] = [];
}
return _cache[type]!;
}
/// Get counters for a specific date from Hive.
Map<String, int> getCountersForDate(String date) {
final box = Hive.box<DzikirCounter>(HiveBoxes.dzikirCounters);
final result = <String, int>{};
for (final key in box.keys) {
final counter = box.get(key);
if (counter != null && counter.date == date) {
result[counter.dzikirId] = counter.count;
}
}
return result;
}
/// Increment a dzikir counter for a specific ID on a specific date.
Future<void> increment(String dzikirId, String date, int target) async {
final box = Hive.box<DzikirCounter>(HiveBoxes.dzikirCounters);
final key = '${dzikirId}_$date';
final existing = box.get(key);
if (existing != null) {
existing.count = (existing.count + 1).clamp(0, target);
await existing.save();
} else {
await box.put(
key,
DzikirCounter(
dzikirId: dzikirId,
date: date,
count: 1,
target: target,
),
);
}
}
/// Get today's date string.
String get todayKey => DateFormat('yyyy-MM-dd').format(DateTime.now());
}

View File

@@ -0,0 +1,108 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
/// Service for EQuran.id v2 API.
/// Provides complete Quran data: Arabic, Indonesian translation,
/// tafsir, and audio from 6 qari.
class EQuranService {
static const String _baseUrl = 'https://equran.id/api/v2';
static final EQuranService instance = EQuranService._();
EQuranService._();
// In-memory cache
List<Map<String, dynamic>>? _surahListCache;
/// Get list of all 114 surahs.
Future<List<Map<String, dynamic>>> getAllSurahs() async {
if (_surahListCache != null) return _surahListCache!;
try {
final response = await http.get(Uri.parse('$_baseUrl/surat'));
if (response.statusCode == 200) {
final data = json.decode(response.body);
if (data['code'] == 200) {
_surahListCache =
List<Map<String, dynamic>>.from(data['data']);
return _surahListCache!;
}
}
} catch (e) {
// silent fallback
}
return [];
}
/// Get full surah with all ayat, audio, etc.
/// Returns the full surah data object.
Future<Map<String, dynamic>?> getSurah(int number) async {
try {
final response =
await http.get(Uri.parse('$_baseUrl/surat/$number'));
if (response.statusCode == 200) {
final data = json.decode(response.body);
if (data['code'] == 200) {
return Map<String, dynamic>.from(data['data']);
}
}
} catch (e) {
// silent fallback
}
return null;
}
/// Get tafsir for a surah.
Future<Map<String, dynamic>?> getTafsir(int number) async {
try {
final response =
await http.get(Uri.parse('$_baseUrl/tafsir/$number'));
if (response.statusCode == 200) {
final data = json.decode(response.body);
if (data['code'] == 200) {
return Map<String, dynamic>.from(data['data']);
}
}
} catch (e) {
// silent fallback
}
return null;
}
/// Get deterministic daily ayat from API
Future<Map<String, dynamic>?> getDailyAyat() async {
try {
final now = DateTime.now();
final dayOfYear = int.parse(now.difference(DateTime(now.year, 1, 1)).inDays.toString());
// Pick surah 1-114
int surahId = (dayOfYear % 114) + 1;
final surahData = await getSurah(surahId);
if (surahData != null && surahData['ayat'] != null) {
int totalAyat = surahData['jumlahAyat'] ?? 1;
int ayatIndex = dayOfYear % totalAyat;
final targetAyat = surahData['ayat'][ayatIndex];
return {
'surahName': surahData['namaLatin'],
'nomorSurah': surahId,
'nomorAyat': targetAyat['nomorAyat'],
'teksArab': targetAyat['teksArab'],
'teksIndonesia': targetAyat['teksIndonesia'],
};
}
} catch (e) {
// silent fallback
}
return null;
}
/// Available qari names mapped to audio key index.
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',
};
}

View File

@@ -0,0 +1,86 @@
import 'package:geolocator/geolocator.dart';
import 'package:geocoding/geocoding.dart' as geocoding;
import 'package:hive_flutter/hive_flutter.dart';
import '../local/hive_boxes.dart';
import '../local/models/app_settings.dart';
/// Location service with GPS + fallback to last known location.
class LocationService {
LocationService._();
static final LocationService instance = LocationService._();
/// Request permission and get current GPS location.
Future<Position?> getCurrentLocation() async {
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) return null;
LocationPermission permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
if (permission == LocationPermission.denied) return null;
}
if (permission == LocationPermission.deniedForever) return null;
try {
final position = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.medium,
timeLimit: Duration(seconds: 10),
),
);
// Save to settings for fallback
await _saveLastKnown(position.latitude, position.longitude);
return position;
} catch (_) {
return null;
}
}
/// Get last known location from Hive settings.
({double lat, double lng, String? cityName})? getLastKnownLocation() {
final settingsBox = Hive.box<AppSettings>(HiveBoxes.settings);
final settings = settingsBox.get('default');
if (settings?.lastLat != null && settings?.lastLng != null) {
return (
lat: settings!.lastLat!,
lng: settings.lastLng!,
cityName: settings.lastCityName,
);
}
return null;
}
/// Reverse geocode to get city name from coordinates.
Future<String> getCityName(double lat, double lng) async {
try {
final placemarks = await geocoding.placemarkFromCoordinates(lat, lng);
if (placemarks.isNotEmpty) {
final place = placemarks.first;
final city = place.locality ?? place.subAdministrativeArea ?? 'Unknown';
final country = place.country ?? '';
return '$city, $country';
}
} catch (_) {
// Geocoding may fail offline — return coords
}
return '${lat.toStringAsFixed(2)}, ${lng.toStringAsFixed(2)}';
}
/// Save last known position to Hive.
Future<void> _saveLastKnown(double lat, double lng) async {
final settingsBox = Hive.box<AppSettings>(HiveBoxes.settings);
final settings = settingsBox.get('default');
if (settings != null) {
settings.lastLat = lat;
settings.lastLng = lng;
try {
settings.lastCityName = await getCityName(lat, lng);
} catch (_) {
// Ignore geocoding errors
}
await settings.save();
}
}
}

View File

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

View File

@@ -0,0 +1,98 @@
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:timezone/timezone.dart' as tz;
/// Notification service for Adhan and Iqamah notifications.
class NotificationService {
NotificationService._();
static final NotificationService instance = NotificationService._();
final FlutterLocalNotificationsPlugin _plugin =
FlutterLocalNotificationsPlugin();
bool _initialized = false;
/// Initialize notification channels.
Future<void> init() async {
if (_initialized) return;
const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
const darwinSettings = DarwinInitializationSettings(
requestAlertPermission: true,
requestBadgePermission: true,
requestSoundPermission: true,
);
const settings = InitializationSettings(
android: androidSettings,
iOS: darwinSettings,
macOS: darwinSettings,
);
await _plugin.initialize(settings);
_initialized = true;
}
/// Schedule an Adhan notification at a specific time.
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,
),
),
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
);
}
/// Schedule an Iqamah reminder notification.
Future<void> scheduleIqamah({
required int id,
required String prayerName,
required DateTime adhanTime,
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,
),
),
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
);
}
/// Cancel all pending notifications.
Future<void> cancelAll() async {
await _plugin.cancelAll();
}
}

View File

@@ -0,0 +1 @@
// TODO: implement

View File

@@ -0,0 +1,126 @@
import 'package:adhan/adhan.dart' as adhan;
import 'package:hive_flutter/hive_flutter.dart';
import '../local/hive_boxes.dart';
import '../local/models/cached_prayer_times.dart';
import 'package:intl/intl.dart';
/// Result object for prayer times.
class PrayerTimesResult {
final DateTime fajr;
final DateTime sunrise;
final DateTime dhuhr;
final DateTime asr;
final DateTime maghrib;
final DateTime isha;
PrayerTimesResult({
required this.fajr,
required this.sunrise,
required this.dhuhr,
required this.asr,
required this.maghrib,
required this.isha,
});
}
/// Prayer time calculation service using the adhan package.
class PrayerService {
PrayerService._();
static final PrayerService instance = PrayerService._();
/// Calculate prayer times for a given location and date.
/// Uses cache if available; writes to cache after calculation.
PrayerTimesResult getPrayerTimes(double lat, double lng, DateTime date) {
final dateKey = DateFormat('yyyy-MM-dd').format(date);
final cacheKey = '${lat.toStringAsFixed(4)}_${lng.toStringAsFixed(4)}_$dateKey';
// Check cache
final cacheBox = Hive.box<CachedPrayerTimes>(HiveBoxes.cachedPrayerTimes);
final cached = cacheBox.get(cacheKey);
if (cached != null) {
return PrayerTimesResult(
fajr: cached.fajr,
sunrise: cached.sunrise,
dhuhr: cached.dhuhr,
asr: cached.asr,
maghrib: cached.maghrib,
isha: cached.isha,
);
}
// Calculate using adhan package
final coordinates = adhan.Coordinates(lat, lng);
final dateComponents = adhan.DateComponents(date.year, date.month, date.day);
final params = adhan.CalculationMethod.muslim_world_league.getParameters();
params.madhab = adhan.Madhab.shafi;
final prayerTimes = adhan.PrayerTimes(coordinates, dateComponents, params);
final result = PrayerTimesResult(
fajr: prayerTimes.fajr!,
sunrise: prayerTimes.sunrise!,
dhuhr: prayerTimes.dhuhr!,
asr: prayerTimes.asr!,
maghrib: prayerTimes.maghrib!,
isha: prayerTimes.isha!,
);
// Cache result
cacheBox.put(
cacheKey,
CachedPrayerTimes(
key: cacheKey,
lat: lat,
lng: lng,
date: dateKey,
fajr: result.fajr,
sunrise: result.sunrise,
dhuhr: result.dhuhr,
asr: result.asr,
maghrib: result.maghrib,
isha: result.isha,
),
);
return result;
}
/// Get the next prayer name and time from now.
MapEntry<String, DateTime>? getNextPrayer(PrayerTimesResult times) {
final now = DateTime.now();
final entries = {
'Fajr': times.fajr,
'Dhuhr': times.dhuhr,
'Asr': times.asr,
'Maghrib': times.maghrib,
'Isha': times.isha,
};
for (final entry in entries.entries) {
if (entry.value.isAfter(now)) {
return entry;
}
}
return null; // All prayers passed for today
}
/// Get the current active prayer (the last prayer whose time has passed).
String? getCurrentPrayer(PrayerTimesResult times) {
final now = DateTime.now();
String? current;
if (now.isAfter(times.isha)) {
current = 'Isha';
} else if (now.isAfter(times.maghrib)) {
current = 'Maghrib';
} else if (now.isAfter(times.asr)) {
current = 'Asr';
} else if (now.isAfter(times.dhuhr)) {
current = 'Dhuhr';
} else if (now.isAfter(times.fajr)) {
current = 'Fajr';
}
return current;
}
}

View File

@@ -0,0 +1,98 @@
import 'dart:convert';
import 'package:flutter/services.dart';
/// Represents a single Surah with its verses.
class Surah {
final int id;
final String nameArabic;
final String nameLatin;
final int verseCount;
final int juzStart;
final String revelationType;
final List<Verse> verses;
Surah({
required this.id,
required this.nameArabic,
required this.nameLatin,
required this.verseCount,
this.juzStart = 1,
this.revelationType = 'Meccan',
this.verses = const [],
});
factory Surah.fromJson(Map<String, dynamic> json) {
return Surah(
id: json['id'] as int,
nameArabic: json['name_arabic'] as String? ?? '',
nameLatin: json['name_latin'] as String? ?? '',
verseCount: json['verse_count'] as int? ?? 0,
juzStart: json['juz_start'] as int? ?? 1,
revelationType: json['revelation_type'] as String? ?? 'Meccan',
verses: (json['verses'] as List<dynamic>?)
?.map((v) => Verse.fromJson(v as Map<String, dynamic>))
.toList() ??
[],
);
}
}
/// A single Quran verse.
class Verse {
final int id;
final String arabic;
final String? transliteration;
final String translationId;
Verse({
required this.id,
required this.arabic,
this.transliteration,
required this.translationId,
});
factory Verse.fromJson(Map<String, dynamic> json) {
return Verse(
id: json['id'] as int,
arabic: json['arabic'] as String? ?? '',
transliteration: json['transliteration'] as String?,
translationId: json['translation_id'] as String? ?? '',
);
}
}
/// Service to load Quran data from bundled JSON asset.
class QuranService {
QuranService._();
static final QuranService instance = QuranService._();
List<Surah>? _cachedSurahs;
/// Load all 114 Surahs from local JSON. Cached in memory after first load.
Future<List<Surah>> getAllSurahs() async {
if (_cachedSurahs != null) return _cachedSurahs!;
try {
final jsonString =
await rootBundle.loadString('assets/quran/quran_id.json');
final List<dynamic> data = json.decode(jsonString);
_cachedSurahs = data
.map((s) => Surah.fromJson(s as Map<String, dynamic>))
.toList();
} catch (_) {
_cachedSurahs = [];
}
return _cachedSurahs!;
}
/// Get a single Surah by ID.
Future<Surah?> getSurah(int id) async {
final surahs = await getAllSurahs();
try {
return surahs.firstWhere((s) => s.id == id);
} catch (_) {
return null;
}
}
}

View File

@@ -0,0 +1,83 @@
import 'dart:convert';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:http/http.dart' as http;
import 'package:hive_flutter/hive_flutter.dart';
/// Service for fetching Islamic-themed photos from Unsplash.
/// Implements aggressive caching to minimize API usage (1 request/day).
class UnsplashService {
static const String _baseUrl = 'https://api.unsplash.com';
static const String _cacheBoxName = 'unsplash_cache';
static const String _cacheKey = 'cached_photo';
static const String _cacheTimestampKey = 'cached_timestamp';
static const Duration _cacheTTL = Duration(hours: 24);
static final UnsplashService instance = UnsplashService._();
UnsplashService._();
// In-memory cache for the current session
Map<String, String>? _memoryCache;
/// Get a cached or fresh Islamic photo.
/// Returns a map with keys: 'imageUrl', 'photographerName', 'photographerUrl', 'unsplashUrl'
Future<Map<String, String>?> getIslamicPhoto() async {
// 1. Check memory cache
if (_memoryCache != null) return _memoryCache;
// 2. Check Hive cache
final box = await Hive.openBox(_cacheBoxName);
final cachedData = box.get(_cacheKey);
final cachedTimestamp = box.get(_cacheTimestampKey);
if (cachedData != null && cachedTimestamp != null) {
final cachedTime = DateTime.fromMillisecondsSinceEpoch(cachedTimestamp);
if (DateTime.now().difference(cachedTime) < _cacheTTL) {
_memoryCache = Map<String, String>.from(json.decode(cachedData));
return _memoryCache;
}
}
// 3. Fetch from API
final photo = await _fetchFromApi();
if (photo != null) {
// Cache in Hive
await box.put(_cacheKey, json.encode(photo));
await box.put(_cacheTimestampKey, DateTime.now().millisecondsSinceEpoch);
_memoryCache = photo;
}
return photo;
}
Future<Map<String, String>?> _fetchFromApi() async {
final accessKey = dotenv.env['UNSPLASH_ACCESS_KEY'];
if (accessKey == null || accessKey.isEmpty || accessKey == 'YOUR_ACCESS_KEY_HERE') {
return null;
}
try {
final queries = ['masjid', 'kaabah', 'mosque', 'islamic architecture'];
// Rotate query based on the day of year for variety
final dayOfYear = DateTime.now().difference(DateTime(DateTime.now().year, 1, 1)).inDays;
final query = queries[dayOfYear % queries.length];
final response = await http.get(
Uri.parse('$_baseUrl/photos/random?query=$query&orientation=portrait&content_filter=high'),
headers: {'Authorization': 'Client-ID $accessKey'},
);
if (response.statusCode == 200) {
final data = json.decode(response.body);
return {
'imageUrl': data['urls']?['regular'] ?? '',
'photographerName': data['user']?['name'] ?? 'Unknown',
'photographerUrl': data['user']?['links']?['html'] ?? '',
'unsplashUrl': data['links']?['html'] ?? '',
};
}
} catch (e) {
// Silent fallback — show the equalizer without background
}
return null;
}
}

View File

@@ -0,0 +1,648 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import '../../../app/theme/app_colors.dart';
import '../../../core/widgets/progress_bar.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/shalat_log.dart';
import '../../../data/local/models/tilawah_log.dart';
import '../../../data/local/models/dzikir_log.dart';
import '../../../data/local/models/puasa_log.dart';
class ChecklistScreen extends ConsumerStatefulWidget {
const ChecklistScreen({super.key});
@override
ConsumerState<ChecklistScreen> createState() => _ChecklistScreenState();
}
class _ChecklistScreenState extends ConsumerState<ChecklistScreen> {
late String _todayKey;
late Box<DailyWorshipLog> _logBox;
late Box<AppSettings> _settingsBox;
late AppSettings _settings;
final List<String> _fardhuPrayers = ['Subuh', 'Dzuhur', 'Ashar', 'Maghrib', 'Isya'];
@override
void initState() {
super.initState();
_todayKey = DateFormat('yyyy-MM-dd').format(DateTime.now());
_logBox = Hive.box<DailyWorshipLog>(HiveBoxes.worshipLogs);
_settingsBox = Hive.box<AppSettings>(HiveBoxes.settings);
_settings = _settingsBox.get('default') ?? AppSettings();
_ensureLogExists();
}
void _ensureLogExists() {
if (!_logBox.containsKey(_todayKey)) {
final shalatLogs = <String, ShalatLog>{};
for (final p in _fardhuPrayers) {
shalatLogs[p.toLowerCase()] = ShalatLog();
}
_logBox.put(
_todayKey,
DailyWorshipLog(
date: _todayKey,
shalatLogs: shalatLogs,
tilawahLog: TilawahLog(
targetValue: _settings.tilawahTargetValue,
targetUnit: _settings.tilawahTargetUnit,
autoSync: _settings.tilawahAutoSync,
),
dzikirLog: _settings.trackDzikir ? DzikirLog() : null,
puasaLog: _settings.trackPuasa ? PuasaLog() : null,
),
);
}
}
DailyWorshipLog get _todayLog => _logBox.get(_todayKey)!;
void _recalculateProgress() {
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.trackPuasa && log.puasaLog == null) log.puasaLog = PuasaLog();
int total = 0;
int completed = 0;
// Shalat
for (final p in _fardhuPrayers) {
final pKey = p.toLowerCase();
final sLog = log.shalatLogs[pKey];
if (sLog != null) {
total++;
if (sLog.completed) completed++;
if (hasQabliyah(pKey, _settings.rawatibLevel)) {
total++;
if (sLog.qabliyah == true) completed++;
}
if (hasBadiyah(pKey, _settings.rawatibLevel)) {
total++;
if (sLog.badiyah == true) completed++;
}
}
}
// Tilawah
if (log.tilawahLog != null) {
total++;
if (log.tilawahLog!.isCompleted) completed++;
}
// Dzikir
if (_settings.trackDzikir && log.dzikirLog != null) {
total += 2;
if (log.dzikirLog!.pagi) completed++;
if (log.dzikirLog!.petang) completed++;
}
// Puasa
if (_settings.trackPuasa && log.puasaLog != null) {
total++;
if (log.puasaLog!.completed) completed++;
}
log.totalItems = total;
log.completedCount = completed;
log.completionPercent = total > 0 ? completed / total : 0.0;
log.save();
setState(() {});
}
bool hasQabliyah(String prayer, int level) {
if (level == 0) return false;
if (prayer == 'subuh') return true;
if (prayer == 'dzuhur') return true;
if (prayer == 'ashar') return level == 2; // Ghairu Muakkad
if (prayer == 'maghrib') return level == 2; // Ghairu Muakkad
if (prayer == 'isya') return level == 2; // Ghairu Muakkad
return false;
}
bool hasBadiyah(String prayer, int level) {
if (level == 0) return false;
if (prayer == 'subuh') return false;
if (prayer == 'dzuhur') return true;
if (prayer == 'ashar') return false;
if (prayer == 'maghrib') return true;
if (prayer == 'isya') return true;
return false;
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
final log = _todayLog;
return Scaffold(
appBar: AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Ibadah Harian'),
Text(
DateFormat('EEEE, d MMM yyyy').format(DateTime.now()),
style: theme.textTheme.bodySmall?.copyWith(
color: isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight,
),
),
],
),
centerTitle: false,
actions: [
IconButton(
onPressed: () {},
icon: const Icon(Icons.notifications_outlined),
),
IconButton(
onPressed: () => context.push('/settings'),
icon: const Icon(Icons.settings_outlined),
),
const SizedBox(width: 8),
],
),
body: ListView(
padding: const EdgeInsets.symmetric(horizontal: 16),
children: [
const SizedBox(height: 12),
_buildProgressCard(log, isDark),
const SizedBox(height: 24),
_sectionLabel('SHOLAT FARDHU & RAWATIB'),
const SizedBox(height: 12),
..._fardhuPrayers.map((p) => _buildShalatCard(p, isDark)).toList(),
const SizedBox(height: 24),
_sectionLabel('TILAWAH AL-QURAN'),
const SizedBox(height: 12),
_buildTilawahCard(isDark),
if (_settings.trackDzikir || _settings.trackPuasa) ...[
const SizedBox(height: 24),
_sectionLabel('AMALAN TAMBAHAN'),
const SizedBox(height: 12),
],
if (_settings.trackDzikir) _buildDzikirCard(isDark),
if (_settings.trackPuasa) _buildPuasaCard(isDark),
const SizedBox(height: 32),
],
),
);
}
Widget _sectionLabel(String text) {
return Text(
text,
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
letterSpacing: 1.5,
color: AppColors.sage,
),
);
}
Widget _buildProgressCard(DailyWorshipLog log, bool isDark) {
final percent = log.completionPercent;
final remaining = log.totalItems - log.completedCount;
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: isDark ? AppColors.surfaceDark : const Color(0xFF2B3441),
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 16,
offset: const Offset(0, 8),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
"POIN HARI INI",
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
letterSpacing: 1.5,
color: AppColors.primary.withValues(alpha: 0.8),
),
),
Container(
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(Icons.stars, color: AppColors.primary, size: 14),
const SizedBox(width: 4),
Text(
'${log.totalPoints} pts',
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: AppColors.primary,
),
),
],
),
),
],
),
const SizedBox(height: 12),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${(percent * 100).round()}%',
style: const TextStyle(
fontSize: 42,
fontWeight: FontWeight.w800,
color: Colors.white,
height: 1.1,
),
),
const SizedBox(width: 8),
const Padding(
padding: EdgeInsets.only(bottom: 8),
child: Text(
'Selesai',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w400,
color: Colors.white70,
),
),
),
],
),
const SizedBox(height: 16),
AppProgressBar(
value: percent,
height: 8,
backgroundColor: Colors.white.withValues(alpha: 0.15),
fillColor: AppColors.primary,
),
const SizedBox(height: 12),
Text(
remaining == 0 && log.totalItems > 0
? 'MasyaAllah! Poin maksimal tercapai hari ini! 🎉'
: 'Kumpulkan poin lebih banyak dengan Sholat di Masjid dan amalan sunnah lainnya!',
style: TextStyle(
fontSize: 13,
color: Colors.white.withValues(alpha: 0.7),
),
),
],
),
);
}
Widget _buildShalatCard(String prayerName, bool isDark) {
final pKey = prayerName.toLowerCase();
final log = _todayLog.shalatLogs[pKey];
if (log == null) return const SizedBox.shrink();
final hasQab = hasQabliyah(pKey, _settings.rawatibLevel);
final hasBad = hasBadiyah(pKey, _settings.rawatibLevel);
final isCompleted = log.completed;
return Container(
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
color: isDark ? AppColors.surfaceDark : AppColors.surfaceLight,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: isCompleted
? AppColors.primary.withValues(alpha: 0.3)
: (isDark ? AppColors.primary.withValues(alpha: 0.08) : AppColors.cream),
),
),
child: Theme(
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
child: ExpansionTile(
tilePadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
leading: Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: isCompleted
? AppColors.primary.withValues(alpha: 0.15)
: (isDark ? AppColors.primary.withValues(alpha: 0.08) : AppColors.cream.withValues(alpha: 0.5)),
borderRadius: BorderRadius.circular(12),
),
child: Icon(Icons.mosque, size: 22, color: isCompleted ? AppColors.primary : AppColors.sage),
),
title: Text(
'Sholat $prayerName',
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 16,
color: isCompleted && isDark ? AppColors.textSecondaryDark : null,
decoration: isCompleted ? TextDecoration.lineThrough : null,
),
),
subtitle: log.location != null
? Text('Di ${log.location}', style: const TextStyle(fontSize: 12, color: AppColors.primary))
: null,
trailing: _CustomCheckbox(
value: isCompleted,
onChanged: (v) {
log.completed = v ?? false;
_recalculateProgress();
},
),
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 SizedBox(width: 16),
_radioOption('Masjid', log, () {
log.location = 'Masjid';
log.completed = true; // Auto-check parent
_recalculateProgress();
}),
const SizedBox(width: 16),
_radioOption('Rumah', log, () {
log.location = 'Rumah';
log.completed = true; // Auto-check parent
_recalculateProgress();
}),
],
),
if (hasQab || hasBad) const SizedBox(height: 12),
if (hasQab)
_sunnahRow('Qabliyah $prayerName', log.qabliyah ?? false, (v) {
log.qabliyah = v;
_recalculateProgress();
}),
if (hasBad)
_sunnahRow('Ba\'diyah $prayerName', log.badiyah ?? false, (v) {
log.badiyah = v;
_recalculateProgress();
}),
],
),
),
);
}
Widget _radioOption(String title, ShalatLog log, VoidCallback onTap) {
final selected = log.location == title;
return GestureDetector(
onTap: onTap,
child: Row(
children: [
Icon(
selected ? Icons.radio_button_checked : Icons.radio_button_off,
size: 18,
color: selected ? AppColors.primary : Colors.grey,
),
const SizedBox(width: 4),
Text(title, style: TextStyle(fontSize: 13, color: selected ? AppColors.primary : null)),
],
),
);
}
Widget _sunnahRow(String title, bool value, ValueChanged<bool?> onChanged) {
return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(title, style: const TextStyle(fontSize: 14)),
_CustomCheckbox(value: value, onChanged: onChanged),
],
),
);
}
Widget _buildTilawahCard(bool isDark) {
final log = _todayLog.tilawahLog;
if (log == null) return const SizedBox.shrink();
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: isDark ? AppColors.surfaceDark : AppColors.surfaceLight,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: log.isCompleted
? AppColors.primary.withValues(alpha: 0.3)
: (isDark ? AppColors.primary.withValues(alpha: 0.08) : AppColors.cream),
),
),
child: Column(
children: [
// ── Row 1: Target + Checkbox ──
Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: log.isCompleted
? AppColors.primary.withValues(alpha: 0.15)
: (isDark ? AppColors.primary.withValues(alpha: 0.08) : AppColors.cream.withValues(alpha: 0.5)),
borderRadius: BorderRadius.circular(12),
),
child: Icon(Icons.menu_book, size: 22, color: log.isCompleted ? AppColors.primary : AppColors.sage),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Tilawah',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
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),
),
],
),
),
_CustomCheckbox(
value: log.targetCompleted,
onChanged: (v) {
log.targetCompleted = v ?? false;
_recalculateProgress();
},
),
],
),
const SizedBox(height: 12),
const Divider(height: 1),
const SizedBox(height: 12),
// ── Row 2: Ayat Tracker ──
Row(
children: [
Icon(Icons.auto_stories, size: 18, color: AppColors.sage),
const SizedBox(width: 8),
Expanded(
child: Text(
'Sudah Baca: ${log.rawAyatRead} Ayat',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight,
),
),
),
if (log.autoSync)
Tooltip(
message: 'Sinkron dari Al-Quran',
child: Icon(Icons.sync, size: 16, color: AppColors.primary),
),
IconButton(
icon: const Icon(Icons.remove_circle_outline, size: 20),
visualDensity: VisualDensity.compact,
onPressed: log.rawAyatRead > 0
? () {
log.rawAyatRead--;
_recalculateProgress();
}
: null,
),
IconButton(
icon: const Icon(Icons.add_circle_outline, size: 20, color: AppColors.primary),
visualDensity: VisualDensity.compact,
onPressed: () {
log.rawAyatRead++;
_recalculateProgress();
},
),
],
),
],
),
);
}
Widget _buildDzikirCard(bool isDark) {
final log = _todayLog.dzikirLog;
if (log == null) return const SizedBox.shrink();
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: isDark ? AppColors.surfaceDark : AppColors.surfaceLight,
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.auto_awesome, size: 20, color: AppColors.sage),
const SizedBox(width: 8),
const Text('Dzikir Harian', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 15)),
],
),
const SizedBox(height: 12),
_sunnahRow('Dzikir Pagi', log.pagi, (v) {
log.pagi = v ?? false;
_recalculateProgress();
}),
_sunnahRow('Dzikir Petang', log.petang, (v) {
log.petang = v ?? false;
_recalculateProgress();
}),
],
),
);
}
Widget _buildPuasaCard(bool isDark) {
final log = _todayLog.puasaLog;
if (log == null) return const SizedBox.shrink();
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: isDark ? AppColors.surfaceDark : AppColors.surfaceLight,
borderRadius: BorderRadius.circular(16),
),
child: Row(
children: [
const Icon(Icons.nightlight_round, size: 20, color: AppColors.sage),
const SizedBox(width: 8),
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))))
.toList(),
onChanged: (v) {
log.jenisPuasa = v;
_recalculateProgress();
},
),
const SizedBox(width: 8),
_CustomCheckbox(
value: log.completed,
onChanged: (v) {
log.completed = v ?? false;
_recalculateProgress();
},
),
],
),
);
}
}
class _CustomCheckbox extends StatelessWidget {
final bool value;
final ValueChanged<bool?> onChanged;
const _CustomCheckbox({required this.value, required this.onChanged});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => onChanged(!value),
child: Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: value ? AppColors.primary : Colors.transparent,
borderRadius: BorderRadius.circular(6),
border: value ? null : Border.all(color: Colors.grey, width: 2),
),
child: value ? const Icon(Icons.check, size: 16, color: Colors.white) : null,
),
);
}
}

View File

@@ -0,0 +1 @@
// TODO: implement

View File

View File

@@ -0,0 +1 @@
// TODO: implement

View File

@@ -0,0 +1,210 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:intl/intl.dart';
import '../../../data/services/myquran_sholat_service.dart';
import '../../../data/services/prayer_service.dart';
import '../../../data/services/location_service.dart';
import '../../../data/local/hive_boxes.dart';
import '../../../data/local/models/app_settings.dart';
/// Represents a single prayer time entry.
class PrayerTimeEntry {
final String name;
final String time; // "HH:mm"
final bool isActive;
PrayerTimeEntry({
required this.name,
required this.time,
this.isActive = false,
});
}
/// Full day prayer schedule from myQuran API.
class DaySchedule {
final String cityName;
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}
DaySchedule({
required this.cityName,
required this.province,
required this.date,
required this.tanggal,
required this.times,
});
/// Is this schedule for tomorrow?
bool get isTomorrow {
final todayStr = DateFormat('yyyy-MM-dd').format(DateTime.now());
return date.compareTo(todayStr) > 0;
}
/// Get prayer time entries as a list.
List<PrayerTimeEntry> get prayerList {
final now = DateTime.now();
final formatter = DateFormat('HH:mm');
final currentTime = formatter.format(now);
final prayers = [
PrayerTimeEntry(name: 'Imsak', time: times['imsak'] ?? '-'),
PrayerTimeEntry(name: 'Subuh', time: times['subuh'] ?? '-'),
PrayerTimeEntry(name: 'Terbit', time: times['terbit'] ?? '-'),
PrayerTimeEntry(name: 'Dhuha', time: times['dhuha'] ?? '-'),
PrayerTimeEntry(name: 'Dzuhur', time: times['dzuhur'] ?? '-'),
PrayerTimeEntry(name: 'Ashar', time: times['ashar'] ?? '-'),
PrayerTimeEntry(name: 'Maghrib', time: times['maghrib'] ?? '-'),
PrayerTimeEntry(name: 'Isya', time: times['isya'] ?? '-'),
];
// Find the next prayer
int activeIndex = -1;
if (isTomorrow) {
// User specifically requested to show tomorrow's Subuh as upcoming
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) {
activeIndex = i;
break;
}
}
}
if (activeIndex >= 0) {
prayers[activeIndex] = PrayerTimeEntry(
name: prayers[activeIndex].name,
time: prayers[activeIndex].time,
isActive: true,
);
}
return prayers;
}
/// Get the next prayer name and time.
PrayerTimeEntry? get nextPrayer {
final list = prayerList;
for (final p in list) {
if (p.isActive) return p;
}
// If none active and it's today, all prayers have passed
return null;
}
}
/// Default Jakarta city ID from myQuran API.
const _defaultCityId = '58a2fc6ed39fd083f55d4182bf88826d';
/// Provider for the user's selected city ID (stored in Hive settings).
final selectedCityIdProvider = StateProvider<String>((ref) {
final box = Hive.box<AppSettings>(HiveBoxes.settings);
final settings = box.get('default');
final stored = settings?.lastCityName ?? '';
if (stored.contains('|')) {
return stored.split('|').last;
}
return _defaultCityId;
});
/// Provider for today's prayer times using myQuran API.
final prayerTimesProvider = FutureProvider<DaySchedule?>((ref) async {
final cityId = ref.watch(selectedCityIdProvider);
final today = DateFormat('yyyy-MM-dd').format(DateTime.now());
DaySchedule? schedule;
// Try API first
final jadwal =
await MyQuranSholatService.instance.getDailySchedule(cityId, today);
if (jadwal != null) {
final cityInfo = await MyQuranSholatService.instance.getCityInfo(cityId);
schedule = DaySchedule(
cityName: cityInfo?['kabko'] ?? 'Jakarta',
province: cityInfo?['prov'] ?? 'DKI Jakarta',
date: today,
tanggal: jadwal['tanggal'] ?? today,
times: jadwal,
);
}
// Check if all prayers today have passed
if (schedule != null && !schedule.isTomorrow && schedule.nextPrayer == null) {
// All prayers passed, fetch tomorrow's schedule
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);
if (tmrwJadwal != null) {
final cityInfo = await MyQuranSholatService.instance.getCityInfo(cityId);
schedule = DaySchedule(
cityName: cityInfo?['kabko'] ?? 'Jakarta',
province: cityInfo?['prov'] ?? 'DKI Jakarta',
date: tomorrowStr,
tanggal: tmrwJadwal['tanggal'] ?? tomorrowStr,
times: tmrwJadwal,
);
}
}
if (schedule != null) {
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 result = PrayerService.instance.getPrayerTimes(lat, lng, DateTime.now());
if (result != null) {
final timeFormat = DateFormat('HH:mm');
return 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))),
'subuh': timeFormat.format(result.fajr),
'terbit': timeFormat.format(result.sunrise),
'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),
},
);
}
return null;
});
/// Provider for monthly prayer schedule (for Imsakiyah screen).
final monthlyScheduleProvider =
FutureProvider.family<Map<String, Map<String, String>>, String>(
(ref, month) async {
final cityId = ref.watch(selectedCityIdProvider);
return MyQuranSholatService.instance.getMonthlySchedule(cityId, month);
});
/// Provider for current city name.
final cityNameProvider = FutureProvider<String>((ref) async {
final box = Hive.box<AppSettings>(HiveBoxes.settings);
final settings = box.get('default');
final stored = settings?.lastCityName ?? '';
if (stored.contains('|')) {
return stored.split('|').first;
}
final cityId = ref.watch(selectedCityIdProvider);
final info = await MyQuranSholatService.instance.getCityInfo(cityId);
if (info != null) {
return '${info['kabko']}, ${info['prov']}';
}
return 'Kota Jakarta, DKI Jakarta';
});

View File

View File

@@ -0,0 +1 @@
// TODO: implement

View File

@@ -0,0 +1,673 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import 'package:go_router/go_router.dart';
import 'package:hive_flutter/hive_flutter.dart';
import '../../../app/theme/app_colors.dart';
import '../../../core/widgets/prayer_time_card.dart';
import '../../../data/local/hive_boxes.dart';
import '../../../data/local/models/daily_worship_log.dart';
import '../data/prayer_times_provider.dart';
class DashboardScreen extends ConsumerStatefulWidget {
const DashboardScreen({super.key});
@override
ConsumerState<DashboardScreen> createState() => _DashboardScreenState();
}
class _DashboardScreenState extends ConsumerState<DashboardScreen> {
Timer? _countdownTimer;
Duration _countdown = Duration.zero;
String _nextPrayerName = '';
final ScrollController _prayerScrollController = ScrollController();
@override
void dispose() {
_countdownTimer?.cancel();
_prayerScrollController.dispose();
super.dispose();
}
void _startCountdown(DaySchedule schedule) {
_countdownTimer?.cancel();
_updateCountdown(schedule);
_countdownTimer = Timer.periodic(const Duration(seconds: 1), (_) {
_updateCountdown(schedule);
});
}
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));
}
setState(() {
_nextPrayerName = next.name;
_countdown = target.difference(now);
if (_countdown.isNegative) _countdown = Duration.zero;
});
}
}
}
String _formatCountdown(Duration d) {
final h = d.inHours.toString().padLeft(2, '0');
final m = (d.inMinutes % 60).toString().padLeft(2, '0');
final s = (d.inSeconds % 60).toString().padLeft(2, '0');
return '$h:$m:$s';
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
final prayerTimesAsync = ref.watch(prayerTimesProvider);
return Scaffold(
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 8),
_buildHeader(context, isDark),
const SizedBox(height: 20),
prayerTimesAsync.when(
data: (schedule) {
if (schedule != null) {
_startCountdown(schedule);
return _buildHeroCard(context, schedule);
}
return _buildHeroCardPlaceholder(context);
},
loading: () => _buildHeroCardPlaceholder(context),
error: (_, __) => _buildHeroCardPlaceholder(context),
),
const SizedBox(height: 24),
_buildPrayerTimesSection(context, prayerTimesAsync),
const SizedBox(height: 24),
_buildChecklistSummary(context, isDark),
const SizedBox(height: 24),
_buildWeeklyProgress(context, isDark),
const SizedBox(height: 24),
],
),
),
),
);
}
Widget _buildHeader(BuildContext context, bool isDark) {
return Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: AppColors.primary, width: 2),
color: AppColors.primary.withValues(alpha: 0.2),
),
child: const Icon(Icons.person, size: 20, color: AppColors.primary),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Selamat datang,',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
),
Text(
"Assalamu'alaikum",
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
],
),
),
Row(
children: [
IconButton(
onPressed: () {},
icon: Icon(
Icons.notifications_outlined,
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
),
IconButton(
onPressed: () => context.push('/settings'),
icon: Icon(
Icons.settings_outlined,
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
),
],
),
],
);
}
Widget _buildHeroCard(BuildContext context, DaySchedule schedule) {
final next = schedule.nextPrayer;
final name = _nextPrayerName.isNotEmpty
? _nextPrayerName
: (next?.name ?? 'Isya');
final time = next?.time ?? '--:--';
return Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: AppColors.primary,
borderRadius: BorderRadius.circular(24),
boxShadow: [
BoxShadow(
color: AppColors.primary.withValues(alpha: 0.3),
blurRadius: 20,
offset: const Offset(0, 8),
),
],
),
child: Stack(
children: [
Positioned(
top: -20,
right: -20,
child: Container(
width: 120,
height: 120,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white.withValues(alpha: 0.15),
),
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.schedule,
size: 16,
color: AppColors.onPrimary.withValues(alpha: 0.8)),
const SizedBox(width: 6),
Text(
'SHOLAT BERIKUTNYA',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
letterSpacing: 1.5,
color: AppColors.onPrimary.withValues(alpha: 0.8),
),
),
],
),
const SizedBox(height: 8),
Text(
'$name$time',
style: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.w800,
color: AppColors.onPrimary,
),
),
const SizedBox(height: 4),
Text(
'Hitung mundur: ${_formatCountdown(_countdown)}',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w400,
color: AppColors.onPrimary.withValues(alpha: 0.8),
),
),
const SizedBox(height: 4),
// City name
Text(
'📍 ${schedule.cityName}',
style: TextStyle(
fontSize: 13,
color: AppColors.onPrimary.withValues(alpha: 0.7),
),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: GestureDetector(
onTap: () => context.push('/tools/qibla'),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: AppColors.onPrimary,
borderRadius: BorderRadius.circular(50),
),
child: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.explore, size: 18, color: Colors.white),
SizedBox(width: 8),
Text(
'Arah Kiblat',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
],
),
),
),
),
const SizedBox(width: 12),
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.2),
shape: BoxShape.circle,
),
child: const Icon(
Icons.volume_up,
color: AppColors.onPrimary,
size: 22,
),
),
],
),
],
),
],
),
);
}
Widget _buildHeroCardPlaceholder(BuildContext context) {
return Container(
width: double.infinity,
height: 180,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: AppColors.primary,
borderRadius: BorderRadius.circular(24),
),
child: const Center(
child: CircularProgressIndicator(color: AppColors.onPrimary),
),
);
}
Widget _buildPrayerTimesSection(
BuildContext context, AsyncValue<DaySchedule?> prayerTimesAsync) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
prayerTimesAsync.value?.isTomorrow == true
? 'Jadwal Sholat Besok'
: 'Jadwal Sholat Hari Ini',
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(fontWeight: FontWeight.w700)),
Container(
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',
style: TextStyle(
color: AppColors.primary,
fontSize: 10,
fontWeight: FontWeight.w700,
letterSpacing: 1.5,
),
),
),
],
),
const SizedBox(height: 12),
SizedBox(
height: 110,
child: prayerTimesAsync.when(
data: (schedule) {
if (schedule == null) return const SizedBox();
final prayers = schedule.prayerList.where(
(p) => ['Subuh', 'Dzuhur', 'Ashar', 'Maghrib', 'Isya']
.contains(p.name),
).toList();
return ListView.separated(
controller: _prayerScrollController,
scrollDirection: Axis.horizontal,
itemCount: prayers.length,
separatorBuilder: (_, __) => const SizedBox(width: 12),
itemBuilder: (context, i) {
final p = prayers[i];
final icon = _prayerIcon(p.name);
// Auto-scroll to active prayer on first build
if (p.isActive && i > 0) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_prayerScrollController.hasClients) {
final targetOffset = i * 124.0; // 112 width + 12 gap
_prayerScrollController.animateTo(
targetOffset.clamp(0, _prayerScrollController.position.maxScrollExtent),
duration: const Duration(milliseconds: 400),
curve: Curves.easeOut,
);
}
});
}
return PrayerTimeCard(
prayerName: p.name,
time: p.time,
icon: icon,
isActive: p.isActive,
);
},
);
},
loading: () =>
const Center(child: CircularProgressIndicator()),
error: (_, __) =>
const Center(child: Text('Gagal memuat jadwal')),
),
),
],
);
}
IconData _prayerIcon(String name) {
switch (name) {
case 'Subuh':
return Icons.wb_twilight;
case 'Dzuhur':
return Icons.wb_sunny;
case 'Ashar':
return Icons.filter_drama;
case 'Maghrib':
return Icons.wb_twilight;
case 'Isya':
return Icons.dark_mode;
default:
return Icons.schedule;
}
}
Widget _buildChecklistSummary(BuildContext context, bool isDark) {
final todayKey = DateFormat('yyyy-MM-dd').format(DateTime.now());
final box = Hive.box<DailyWorshipLog>(HiveBoxes.worshipLogs);
final log = box.get(todayKey);
final points = log?.totalPoints ?? 0;
// We can assume a max "excellent" day is around 150 points for the progress ring scale
final percent = (points / 150).clamp(0.0, 1.0);
// Prepare dynamic preview lines
int fardhuCompleted = 0;
if (log != null) {
fardhuCompleted = log.shalatLogs.values.where((l) => l.completed).length;
}
String amalanText = 'Belum ada data';
if (log != null) {
List<String> aList = [];
if (log.tilawahLog?.isCompleted == true) aList.add('Tilawah');
if (log.puasaLog?.completed == true) aList.add('Puasa');
if (log.dzikirLog?.pagi == true) aList.add('Dzikir');
if (aList.isNotEmpty) {
amalanText = aList.join(', ');
}
}
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: isDark ? AppColors.surfaceDark : AppColors.surfaceLight,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: isDark
? AppColors.primary.withValues(alpha: 0.1)
: AppColors.cream,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Poin Ibadah Hari Ini',
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(fontWeight: FontWeight.w700)),
const SizedBox(height: 4),
Text(
'Kumpulkan poin dengan konsisten!',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
),
],
),
),
SizedBox(
width: 48,
height: 48,
child: Stack(
alignment: Alignment.center,
children: [
CircularProgressIndicator(
value: percent,
strokeWidth: 4,
backgroundColor:
AppColors.primary.withValues(alpha: 0.15),
valueColor: const AlwaysStoppedAnimation<Color>(
AppColors.primary),
),
Text(
'$points',
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w800,
color: AppColors.primary,
),
),
],
),
),
],
),
const SizedBox(height: 16),
_checklistPreviewItem(
context, isDark, 'Sholat Fardhu', '$fardhuCompleted dari 5 selesai', fardhuCompleted == 5),
const SizedBox(height: 8),
_checklistPreviewItem(
context, isDark, 'Amalan Selesai', amalanText, points > 50),
const SizedBox(height: 16),
GestureDetector(
onTap: () => context.go('/checklist'),
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: AppColors.primary.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(50),
),
child: const Center(
child: Text(
'Lihat Semua Checklist',
style: TextStyle(
color: AppColors.primary,
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
),
),
),
],
),
);
}
Widget _checklistPreviewItem(BuildContext context, bool isDark, String title,
String subtitle, bool completed) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: isDark
? AppColors.primary.withValues(alpha: 0.05)
: AppColors.backgroundLight,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(
completed ? Icons.check_circle : Icons.radio_button_unchecked,
color: AppColors.primary,
size: 22,
),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title,
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(fontWeight: FontWeight.w600)),
Text(subtitle,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
)),
],
),
],
),
);
}
Widget _buildWeeklyProgress(BuildContext context, bool isDark) {
final box = Hive.box<DailyWorshipLog>(HiveBoxes.worshipLogs);
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 daysLabels = ['Sen', 'Sel', 'Rab', 'Kam', 'Jum', 'Sab', 'Min'];
final weekPoints = <int>[];
for (final d in last7Days) {
final k = DateFormat('yyyy-MM-dd').format(d);
final l = box.get(k);
weekPoints.add(l?.totalPoints ?? 0);
}
// Find the max points acquired this week to scale the bars, with a minimum floor of 50
final maxPts = weekPoints.reduce((a, b) => a > b ? a : b).clamp(50, 300);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Progres Poin Mingguan',
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(fontWeight: FontWeight.w700)),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: isDark ? AppColors.surfaceDark : AppColors.surfaceLight,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: isDark
? AppColors.primary.withValues(alpha: 0.1)
: AppColors.cream,
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: List.generate(7, (i) {
final val = weekPoints[i];
final ratio = (val / maxPts).clamp(0.1, 1.0);
return Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
height: 80,
child: Align(
alignment: Alignment.bottomCenter,
child: 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),
borderRadius: BorderRadius.circular(12),
),
),
),
),
const SizedBox(height: 8),
Text(
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),
),
),
],
),
),
);
}),
),
),
],
);
}
}

View File

@@ -0,0 +1 @@
// TODO: implement

View File

@@ -0,0 +1,306 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:intl/intl.dart';
import '../../../app/theme/app_colors.dart';
import '../../../data/local/hive_boxes.dart';
import '../../../data/local/models/dzikir_counter.dart';
class DzikirScreen extends ConsumerStatefulWidget {
const DzikirScreen({super.key});
@override
ConsumerState<DzikirScreen> createState() => _DzikirScreenState();
}
class _DzikirScreenState extends ConsumerState<DzikirScreen>
with SingleTickerProviderStateMixin {
late TabController _tabController;
List<Map<String, dynamic>> _pagiItems = [];
List<Map<String, dynamic>> _petangItems = [];
late Box<DzikirCounter> _counterBox;
late String _todayKey;
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
_counterBox = Hive.box<DzikirCounter>(HiveBoxes.dzikirCounters);
_todayKey = DateFormat('yyyy-MM-dd').format(DateTime.now());
_loadData();
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
Future<void> _loadData() async {
final pagiJson =
await rootBundle.loadString('assets/dzikir/dzikir_pagi.json');
final petangJson =
await rootBundle.loadString('assets/dzikir/dzikir_petang.json');
setState(() {
_pagiItems = List<Map<String, dynamic>>.from(json.decode(pagiJson));
_petangItems = List<Map<String, dynamic>>.from(json.decode(petangJson));
});
}
DzikirCounter _getCounter(String dzikirId, int target) {
final key = '${dzikirId}_$_todayKey';
return _counterBox.get(key) ??
DzikirCounter(
dzikirId: dzikirId,
date: _todayKey,
count: 0,
target: target,
);
}
void _increment(String dzikirId, int target) {
final key = '${dzikirId}_$_todayKey';
var counter = _counterBox.get(key);
if (counter == null) {
counter = DzikirCounter(
dzikirId: dzikirId,
date: _todayKey,
count: 1,
target: target,
);
_counterBox.put(key, counter);
} else {
if (counter.count < counter.target) {
counter.count++;
counter.save();
}
}
setState(() {});
// Haptic feedback
HapticFeedback.lightImpact();
}
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
appBar: AppBar(
title: const Text('Dzikir Pagi & Petang'),
actions: [
IconButton(
onPressed: () {},
icon: const Icon(Icons.info_outline),
),
],
),
body: Column(
children: [
// Tabs
Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
child: TabBar(
controller: _tabController,
labelColor: AppColors.primary,
unselectedLabelColor: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
indicatorColor: AppColors.primary,
indicatorWeight: 3,
labelStyle:
const TextStyle(fontWeight: FontWeight.w700, fontSize: 14),
tabs: const [
Tab(text: 'Pagi'),
Tab(text: 'Petang'),
],
),
),
Expanded(
child: TabBarView(
controller: _tabController,
children: [
_buildDzikirList(context, isDark, _pagiItems, 'pagi',
'Dzikir Pagi', 'Dibaca setelah shalat Shubuh hingga terbit matahari'),
_buildDzikirList(context, isDark, _petangItems, 'petang',
'Dzikir Petang', 'Dibaca setelah shalat Ashar hingga terbenam matahari'),
],
),
),
],
),
);
}
Widget _buildDzikirList(BuildContext context, bool isDark,
List<Map<String, dynamic>> items, String prefix, String title, String subtitle) {
if (items.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: items.length + 1, // +1 for header
itemBuilder: (context, index) {
if (index == 0) {
return Padding(
padding: const EdgeInsets.only(bottom: 20),
child: Column(
children: [
Text(title,
style: const TextStyle(
fontSize: 22, fontWeight: FontWeight.w800)),
const SizedBox(height: 4),
Text(
subtitle,
textAlign: TextAlign.center,
style: TextStyle(
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
fontSize: 13,
),
),
],
),
);
}
final item = items[index - 1];
final dzikirId = '${prefix}_${item['id']}';
final target = (item['count'] as num?)?.toInt() ?? 1;
final counter = _getCounter(dzikirId, target);
final isComplete = counter.count >= counter.target;
return Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: isDark ? AppColors.surfaceDark : AppColors.surfaceLight,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: isComplete
? AppColors.primary.withValues(alpha: 0.3)
: (isDark
? AppColors.primary.withValues(alpha: 0.08)
: AppColors.cream),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header row: count badge + number
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: AppColors.primary.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(50),
),
child: Text(
'$target KALI',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
color: AppColors.primary,
),
),
),
Text(
'${(index).toString().padLeft(2, '0')}',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
),
],
),
const SizedBox(height: 16),
// Arabic text
SizedBox(
width: double.infinity,
child: Text(
item['arabic'] ?? '',
textAlign: TextAlign.right,
style: const TextStyle(
fontFamily: 'Amiri',
fontSize: 24,
height: 2.0,
),
),
),
const SizedBox(height: 12),
// Transliteration
Text(
item['transliteration'] ?? '',
style: TextStyle(
fontSize: 13,
fontStyle: FontStyle.italic,
color: AppColors.primary,
),
),
const SizedBox(height: 8),
// Translation
Text(
'"${item['translation'] ?? ''}"',
style: TextStyle(
fontSize: 13,
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
),
const SizedBox(height: 16),
// Counter button
GestureDetector(
onTap: () => _increment(dzikirId, target),
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 14),
decoration: BoxDecoration(
color: isComplete
? AppColors.primary.withValues(alpha: 0.15)
: AppColors.primary,
borderRadius: BorderRadius.circular(50),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
isComplete ? Icons.check : Icons.touch_app,
size: 18,
color: isComplete
? AppColors.primary
: AppColors.onPrimary,
),
const SizedBox(width: 8),
Text(
'${counter.count} / $target',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: isComplete
? AppColors.primary
: AppColors.onPrimary,
),
),
],
),
),
),
],
),
),
);
},
);
}
}

View File

@@ -0,0 +1 @@
// TODO: implement

View File

@@ -0,0 +1,557 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import '../../../app/theme/app_colors.dart';
import '../../../data/local/hive_boxes.dart';
import '../../../data/local/models/app_settings.dart';
import '../../../data/services/prayer_service.dart';
import '../../../data/services/myquran_sholat_service.dart';
import '../../dashboard/data/prayer_times_provider.dart';
class ImsakiyahScreen extends ConsumerStatefulWidget {
const ImsakiyahScreen({super.key});
@override
ConsumerState<ImsakiyahScreen> createState() => _ImsakiyahScreenState();
}
class _ImsakiyahScreenState extends ConsumerState<ImsakiyahScreen> {
int _selectedMonthIndex = 0;
late List<_MonthOption> _months;
late AppSettings _settings;
@override
void initState() {
super.initState();
final box = Hive.box<AppSettings>(HiveBoxes.settings);
_settings = box.get('default') ?? AppSettings();
_months = _generateMonths();
// Find current month
final now = DateTime.now();
for (int i = 0; i < _months.length; i++) {
if (_months[i].month == now.month && _months[i].year == now.year) {
_selectedMonthIndex = i;
break;
}
}
}
List<_MonthOption> _generateMonths() {
final now = DateTime.now();
final list = <_MonthOption>[];
for (int offset = -2; offset <= 3; offset++) {
final date = DateTime(now.year, now.month + offset, 1);
list.add(_MonthOption(
label: DateFormat('MMMM yyyy').format(date),
month: date.month,
year: date.year,
));
}
return list;
}
List<_DayRow> _createRows(Map<String, Map<String, String>>? apiData) {
final selected = _months[_selectedMonthIndex];
final daysInMonth =
DateTime(selected.year, selected.month + 1, 0).day;
final rows = <_DayRow>[];
for (int d = 1; d <= daysInMonth; d++) {
final date = DateTime(selected.year, selected.month, d);
final dateStr = DateFormat('yyyy-MM-dd').format(date);
if (apiData != null && apiData.containsKey(dateStr)) {
final times = apiData[dateStr]!;
rows.add(_DayRow(
date: date,
fajr: times['subuh'] ?? '-',
sunrise: times['terbit'] ?? '-',
dhuhr: times['dzuhur'] ?? '-',
asr: times['ashar'] ?? '-',
maghrib: times['maghrib'] ?? '-',
isha: times['isya'] ?? '-',
));
} else {
final times =
PrayerService.instance.getPrayerTimes(-6.2088, 106.8456, date);
rows.add(_DayRow(
date: date,
fajr: DateFormat('HH:mm').format(times.fajr),
sunrise: DateFormat('HH:mm').format(times.sunrise),
dhuhr: DateFormat('HH:mm').format(times.dhuhr),
asr: DateFormat('HH:mm').format(times.asr),
maghrib: DateFormat('HH:mm').format(times.maghrib),
isha: DateFormat('HH:mm').format(times.isha),
));
}
}
return rows;
}
void _showLocationDialog(BuildContext context) {
final searchCtrl = TextEditingController();
bool isSearching = false;
List<Map<String, dynamic>> results = [];
showDialog(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setDialogState) => AlertDialog(
insetPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
title: const Text('Cari Kota/Kabupaten'),
content: SizedBox(
width: MediaQuery.of(context).size.width * 0.85,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: searchCtrl,
autofocus: true,
decoration: InputDecoration(
hintText: 'Cth: Jakarta',
border: const OutlineInputBorder(),
suffixIcon: IconButton(
icon: const Icon(Icons.search),
onPressed: () async {
if (searchCtrl.text.trim().isEmpty) return;
setDialogState(() => isSearching = true);
final res = await MyQuranSholatService.instance
.searchCity(searchCtrl.text.trim());
if (mounted) {
setDialogState(() {
results = res;
isSearching = false;
});
}
},
),
),
onSubmitted: (val) async {
if (val.trim().isEmpty) return;
setDialogState(() => isSearching = true);
final res = await MyQuranSholatService.instance
.searchCity(val.trim());
if (mounted) {
setDialogState(() {
results = res;
isSearching = false;
});
}
},
),
const SizedBox(height: 16),
if (isSearching)
const Center(child: CircularProgressIndicator())
else if (results.isEmpty)
const Text('Tidak ada hasil', style: TextStyle(color: Colors.grey))
else
SizedBox(
height: 200,
width: double.maxFinite,
child: ListView.builder(
shrinkWrap: true,
itemCount: results.length,
itemBuilder: (context, i) {
final city = results[i];
return ListTile(
title: Text(city['lokasi'] ?? ''),
onTap: () {
final id = city['id'];
final name = city['lokasi'];
if (id != null && name != null) {
_settings.lastCityName = '$name|$id';
_settings.save();
// Update providers to refresh data
ref.invalidate(selectedCityIdProvider);
ref.invalidate(cityNameProvider);
Navigator.pop(ctx);
}
},
);
},
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Batal'),
),
],
),
),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
final today = DateTime.now();
final selectedMonth = _months[_selectedMonthIndex];
final monthArg = '${selectedMonth.year}-${selectedMonth.month.toString().padLeft(2, '0')}';
final cityNameAsync = ref.watch(cityNameProvider);
final monthlyDataAsync = ref.watch(monthlyScheduleProvider(monthArg));
return Scaffold(
appBar: AppBar(
title: const Text('Kalender Sholat'),
centerTitle: false,
actions: [
IconButton(
onPressed: () {},
icon: const Icon(Icons.notifications_outlined),
),
IconButton(
onPressed: () => context.push('/settings'),
icon: const Icon(Icons.settings_outlined),
),
const SizedBox(width: 8),
],
),
body: Column(
children: [
// ── Month Selector ──
SizedBox(
height: 48,
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
itemCount: _months.length,
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (context, i) {
final isSelected = i == _selectedMonthIndex;
return GestureDetector(
onTap: () => setState(() => _selectedMonthIndex = i),
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
decoration: BoxDecoration(
color: isSelected
? AppColors.primary
: (isDark ? AppColors.surfaceDark : AppColors.surfaceLight),
borderRadius: BorderRadius.circular(50),
border: isSelected
? null
: Border.all(
color: isDark
? AppColors.primary.withValues(alpha: 0.2)
: AppColors.cream,
),
),
child: Center(
child: Text(
_months[i].label,
style: TextStyle(
fontSize: 13,
fontWeight:
isSelected ? FontWeight.w600 : FontWeight.w400,
color: isSelected
? AppColors.onPrimary
: (isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight),
),
),
),
),
);
},
),
),
const SizedBox(height: 12),
// ── Location Card ──
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: GestureDetector(
onTap: () => _showLocationDialog(context),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: isDark ? AppColors.surfaceDark : AppColors.surfaceLight,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: isDark
? AppColors.primary.withValues(alpha: 0.1)
: AppColors.cream,
),
),
child: Row(
children: [
const Icon(Icons.location_on,
color: AppColors.primary, size: 24),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Lokasi Anda',
style: theme.textTheme.bodySmall?.copyWith(
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
),
Text(
cityNameAsync.value ?? 'Jakarta, Indonesia',
style: const TextStyle(
fontWeight: FontWeight.w600, fontSize: 15),
),
],
),
),
Icon(Icons.expand_more,
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight),
],
),
),
),
),
const SizedBox(height: 16),
// ── Table Header ──
Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: AppColors.primary.withValues(alpha: 0.1),
borderRadius:
const BorderRadius.vertical(top: Radius.circular(12)),
),
child: Row(
children: [
_headerCell('TGL', flex: 4),
_headerCell('SUBUH', flex: 3),
_headerCell('SYURUQ', flex: 3),
_headerCell('DZUHUR', flex: 3),
_headerCell('ASHAR', flex: 3),
_headerCell('MAGH', flex: 3),
_headerCell('ISYA', flex: 3),
],
),
),
// ── Table Body ──
Expanded(
child: monthlyDataAsync.when(
data: (apiData) {
final rows = _createRows(apiData);
return ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: rows.length,
itemBuilder: (context, i) {
final row = rows[i];
final isToday = row.date.day == today.day &&
row.date.month == today.month &&
row.date.year == today.year;
return Container(
margin: const EdgeInsets.only(bottom: 4),
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 14),
decoration: BoxDecoration(
color: isToday
? AppColors.primary
: (isDark
? AppColors.surfaceDark
: AppColors.surfaceLight),
borderRadius: BorderRadius.circular(12),
border: isToday
? null
: Border.all(
color: isDark
? AppColors.primary.withValues(alpha: 0.05)
: AppColors.cream.withValues(alpha: 0.5),
),
),
child: Row(
children: [
// Day column
Expanded(
flex: 4,
child: Column(
children: [
Text(
DateFormat('MMM')
.format(row.date)
.toUpperCase(),
style: TextStyle(
fontSize: 9,
fontWeight: FontWeight.w700,
letterSpacing: 1,
color: isToday
? AppColors.onPrimary
.withValues(alpha: 0.7)
: AppColors.sage,
),
),
Text(
'${row.date.day}',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w800,
color: isToday ? AppColors.onPrimary : null,
),
),
],
),
),
_dataCell(row.fajr, isToday, flex: 3),
_dataCell(row.sunrise, isToday, flex: 3),
_dataCell(row.dhuhr, isToday, bold: true, flex: 3),
_dataCell(row.asr, isToday, flex: 3),
_dataCell(row.maghrib, isToday, bold: true, flex: 3),
_dataCell(row.isha, isToday, flex: 3),
],
),
);
},
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (_, __) {
final rows = _createRows(null); // fallback
return ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: rows.length,
itemBuilder: (context, i) {
final row = rows[i];
final isToday = row.date.day == today.day &&
row.date.month == today.month &&
row.date.year == today.year;
return Container(
margin: const EdgeInsets.only(bottom: 4),
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 14),
decoration: BoxDecoration(
color: isToday
? AppColors.primary
: (isDark
? AppColors.surfaceDark
: AppColors.surfaceLight),
borderRadius: BorderRadius.circular(12),
border: isToday
? null
: Border.all(
color: isDark
? AppColors.primary.withValues(alpha: 0.05)
: AppColors.cream.withValues(alpha: 0.5),
),
),
child: Row(
children: [
Expanded(
flex: 4,
child: Column(
children: [
Text(
DateFormat('MMM')
.format(row.date)
.toUpperCase(),
style: TextStyle(
fontSize: 9,
fontWeight: FontWeight.w700,
letterSpacing: 1,
color: isToday
? AppColors.onPrimary
.withValues(alpha: 0.7)
: AppColors.sage,
),
),
Text(
'${row.date.day}',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w800,
color: isToday ? AppColors.onPrimary : null,
),
),
],
),
),
_dataCell(row.fajr, isToday, flex: 3),
_dataCell(row.sunrise, isToday, flex: 3),
_dataCell(row.dhuhr, isToday, bold: true, flex: 3),
_dataCell(row.asr, isToday, flex: 3),
_dataCell(row.maghrib, isToday, bold: true, flex: 3),
_dataCell(row.isha, isToday, flex: 3),
],
),
);
},
);
},
),
),
],
),
);
}
Widget _headerCell(String text, {int flex = 1}) {
return Expanded(
flex: flex,
child: Center(
child: Text(
text,
style: TextStyle(
fontSize: 9,
fontWeight: FontWeight.w700,
letterSpacing: 1,
color: AppColors.textSecondaryLight,
),
),
),
);
}
Widget _dataCell(String value, bool isToday,
{bool bold = false, int flex = 1}) {
return Expanded(
flex: flex,
child: Center(
child: Text(
value,
style: TextStyle(
fontSize: 12,
fontWeight: bold ? FontWeight.w700 : FontWeight.w400,
color: isToday ? AppColors.onPrimary : null,
),
),
),
);
}
}
class _MonthOption {
final String label;
final int month;
final int year;
_MonthOption({required this.label, required this.month, required this.year});
}
class _DayRow {
final DateTime date;
final String fajr, sunrise, dhuhr, asr, maghrib, isha;
_DayRow({
required this.date,
required this.fajr,
required this.sunrise,
required this.dhuhr,
required this.asr,
required this.maghrib,
required this.isha,
});
}

View File

@@ -0,0 +1 @@
// TODO: implement

View File

@@ -0,0 +1,566 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:intl/intl.dart';
import '../../../app/theme/app_colors.dart';
import '../../../core/widgets/progress_bar.dart';
import '../../../data/local/hive_boxes.dart';
import '../../../data/local/models/daily_worship_log.dart';
import '../../../data/local/models/checklist_item.dart';
class LaporanScreen extends ConsumerStatefulWidget {
const LaporanScreen({super.key});
@override
ConsumerState<LaporanScreen> createState() => _LaporanScreenState();
}
class _LaporanScreenState extends ConsumerState<LaporanScreen>
with SingleTickerProviderStateMixin {
late TabController _tabController;
@override
void initState() {
super.initState();
_tabController = TabController(length: 3, vsync: this);
_tabController.addListener(() => setState(() {}));
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
/// Get the last 7 days' point data.
List<_DayData> _getWeeklyData() {
final logBox = Hive.box<DailyWorshipLog>(HiveBoxes.worshipLogs);
final now = DateTime.now();
final data = <_DayData>[];
for (int i = 6; i >= 0; i--) {
final date = now.subtract(Duration(days: i));
final key = DateFormat('yyyy-MM-dd').format(date);
final log = logBox.get(key);
data.add(_DayData(
label: DateFormat('E').format(date).substring(0, 3),
value: (log?.totalPoints ?? 0).toDouble(), // Use points instead of %
isToday: i == 0,
));
}
return data;
}
/// Get average points for the week.
double _weekAverage(List<_DayData> data) {
if (data.isEmpty) return 0;
final sum = data.fold<double>(0, (s, d) => s + d.value);
return sum / data.length;
}
/// Find best and worst performing items.
_InsightPair _getInsights() {
final logBox = Hive.box<DailyWorshipLog>(HiveBoxes.worshipLogs);
final now = DateTime.now();
final completionCounts = <String, int>{};
final totalCounts = <String, int>{};
int daysChecked = 0;
for (int i = 0; i < 7; i++) {
final date = now.subtract(Duration(days: i));
final key = DateFormat('yyyy-MM-dd').format(date);
final log = logBox.get(key);
if (log != null && log.totalItems > 0) {
daysChecked++;
// Fardhu
totalCounts['fardhu'] = (totalCounts['fardhu'] ?? 0) + 5;
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 (rawatibTotal > 0) {
totalCounts['rawatib'] = (totalCounts['rawatib'] ?? 0) + rawatibTotal;
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;
}
}
// 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;
}
// Puasa
if (log.puasaLog != null) {
totalCounts['puasa'] = (totalCounts['puasa'] ?? 0) + 1;
if (log.puasaLog!.completed) {
completionCounts['puasa'] = (completionCounts['puasa'] ?? 0) + 1;
}
}
}
}
if (daysChecked == 0 || totalCounts.isEmpty) {
return _InsightPair(
best: _InsightItem(title: 'Sholat Fardhu', percent: 0),
worst: _InsightItem(title: 'Belum Ada Data', percent: 0),
);
}
String bestId = totalCounts.keys.first;
String worstId = totalCounts.keys.first;
double bestRate = -1.0;
double worstRate = 2.0;
for (final id in totalCounts.keys) {
final total = totalCounts[id]!;
final completed = completionCounts[id] ?? 0;
final rate = completed / total;
if (rate > bestRate) {
bestRate = rate;
bestId = id;
}
if (rate < worstRate) {
worstRate = rate;
worstId = id;
}
}
final idToTitle = {
'fardhu': 'Sholat Fardhu',
'rawatib': 'Sholat Rawatib',
'tilawah': 'Tilawah Quran',
'dzikir': 'Dzikir Harian',
'puasa': 'Puasa Sunnah',
};
return _InsightPair(
best: _InsightItem(
title: idToTitle[bestId] ?? bestId,
percent: (bestRate * 100).round(),
),
worst: _InsightItem(
title: idToTitle[worstId] ?? worstId,
percent: (worstRate * 100).round(),
),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
final weekData = _getWeeklyData();
final avgPercent = _weekAverage(weekData);
final insights = _getInsights();
return Scaffold(
appBar: AppBar(
title: const Text('Laporan Kualitas Ibadah'),
centerTitle: false,
actions: [
IconButton(
onPressed: () {},
icon: const Icon(Icons.notifications_outlined),
),
IconButton(
onPressed: () => context.push('/settings'),
icon: const Icon(Icons.settings_outlined),
),
const SizedBox(width: 8),
],
),
body: Column(
children: [
// ── Tab Bar ──
Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: isDark
? AppColors.primary.withValues(alpha: 0.1)
: AppColors.cream,
),
),
),
child: TabBar(
controller: _tabController,
labelColor: AppColors.primary,
unselectedLabelColor: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
indicatorColor: AppColors.primary,
indicatorWeight: 3,
labelStyle: const TextStyle(
fontWeight: FontWeight.w700,
fontSize: 14,
),
tabs: const [
Tab(text: 'Mingguan'),
Tab(text: 'Bulanan'),
Tab(text: 'Tahunan'),
],
),
),
// ── Tab Content ──
Expanded(
child: TabBarView(
controller: _tabController,
children: [
_buildWeeklyView(context, isDark, weekData, avgPercent, insights),
_buildComingSoon(context, 'Bulanan'),
_buildComingSoon(context, 'Tahunan'),
],
),
),
],
),
);
}
Widget _buildWeeklyView(
BuildContext context,
bool isDark,
List<_DayData> weekData,
double avgPercent,
_InsightPair insights,
) {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ── Completion Card ──
Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: isDark ? AppColors.surfaceDark : AppColors.surfaceLight,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: isDark
? AppColors.primary.withValues(alpha: 0.1)
: AppColors.cream,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Poin Rata-Rata Harian',
style: TextStyle(
fontSize: 13,
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppColors.primary.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10),
),
child: const Icon(Icons.stars,
color: AppColors.primary, size: 18),
),
],
),
const SizedBox(height: 4),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${avgPercent.round()} pt',
style: const TextStyle(
fontSize: 36,
fontWeight: FontWeight.w800,
height: 1.1,
),
),
],
),
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);
return Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: weekData.map((d) {
final ratio = (d.value / maxPts).clamp(0.05, 1.0);
return Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Flexible(
child: Container(
width: double.infinity,
height: 120 * ratio,
decoration: BoxDecoration(
color: d.isToday
? AppColors.primary
: AppColors.primary
.withValues(alpha: 0.3 + ratio * 0.4),
borderRadius: BorderRadius.circular(6),
),
),
),
const SizedBox(height: 8),
Text(
d.label,
style: TextStyle(
fontSize: 10,
fontWeight: d.isToday
? FontWeight.w700
: FontWeight.w400,
color: d.isToday
? AppColors.primary
: (isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight),
),
),
],
),
),
);
}).toList(),
);
}
),
),
],
),
),
const SizedBox(height: 24),
// ── Insights ──
Text('Wawasan',
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(fontWeight: FontWeight.w700)),
const SizedBox(height: 12),
// Best performing
_insightCard(
context,
isDark,
icon: Icons.star,
iconBg: AppColors.primary.withValues(alpha: 0.15),
iconColor: AppColors.primary,
label: 'PALING RAJIN',
title: insights.best.title,
percent: insights.best.percent,
percentColor: AppColors.primary,
),
const SizedBox(height: 10),
// Needs improvement
_insightCard(
context,
isDark,
icon: Icons.trending_up,
iconBg: const Color(0xFFFFF3E0),
iconColor: Colors.orange,
label: 'PERLU DITINGKATKAN',
title: insights.worst.title,
percent: insights.worst.percent,
percentColor: Colors.orange,
),
const SizedBox(height: 24),
// ── Motivational Quote ──
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: [
Text(
'',
style: TextStyle(
fontSize: 32,
color: AppColors.primary,
height: 0.8,
),
),
const SizedBox(height: 4),
Text(
'"Amal yang paling dicintai Allah adalah yang paling konsisten, meskipun sedikit."',
style: TextStyle(
fontSize: 15,
fontStyle: FontStyle.italic,
height: 1.5,
color: isDark ? Colors.white : Colors.black87,
),
),
const SizedBox(height: 12),
Text(
'— Shahih Bukhari',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
),
],
),
),
const SizedBox(height: 24),
],
),
);
}
Widget _insightCard(
BuildContext context,
bool isDark, {
required IconData icon,
required Color iconBg,
required Color iconColor,
required String label,
required String title,
required int percent,
required Color percentColor,
}) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: isDark ? AppColors.surfaceDark : AppColors.surfaceLight,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: isDark
? AppColors.primary.withValues(alpha: 0.1)
: AppColors.cream,
),
),
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: iconBg,
borderRadius: BorderRadius.circular(12),
),
child: Icon(icon, color: iconColor, size: 22),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
fontSize: 9,
fontWeight: FontWeight.w700,
letterSpacing: 1.5,
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
),
const SizedBox(height: 2),
Text(
title,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
],
),
),
Text(
'$percent%',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w800,
color: percentColor,
),
),
],
),
);
}
Widget _buildComingSoon(BuildContext context, String period) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.bar_chart,
size: 48, color: AppColors.primary.withValues(alpha: 0.3)),
const SizedBox(height: 12),
Text(
'Laporan $period',
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
const SizedBox(height: 4),
Text(
'Segera hadir',
style: TextStyle(
color: Theme.of(context).brightness == Brightness.dark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight),
),
],
),
);
}
}
class _DayData {
final String label;
final double value;
final bool isToday;
_DayData({required this.label, required this.value, this.isToday = false});
}
class _InsightItem {
final String title;
final int percent;
_InsightItem({required this.title, required this.percent});
}
class _InsightPair {
final _InsightItem best;
final _InsightItem worst;
_InsightPair({required this.best, required this.worst});
}

View File

@@ -0,0 +1 @@
// TODO: implement

View File

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