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:
0
lib/core/providers/.gitkeep
Normal file
0
lib/core/providers/.gitkeep
Normal file
1
lib/core/providers/placeholder.dart
Normal file
1
lib/core/providers/placeholder.dart
Normal file
@@ -0,0 +1 @@
|
||||
// TODO: implement
|
||||
12
lib/core/providers/theme_provider.dart
Normal file
12
lib/core/providers/theme_provider.dart
Normal 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;
|
||||
});
|
||||
54
lib/core/providers/tilawah_tracking_provider.dart
Normal file
54
lib/core/providers/tilawah_tracking_provider.dart
Normal 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
0
lib/core/utils/.gitkeep
Normal file
1
lib/core/utils/placeholder.dart
Normal file
1
lib/core/utils/placeholder.dart
Normal file
@@ -0,0 +1 @@
|
||||
// TODO: implement
|
||||
0
lib/core/widgets/.gitkeep
Normal file
0
lib/core/widgets/.gitkeep
Normal file
66
lib/core/widgets/bottom_nav_bar.dart
Normal file
66
lib/core/widgets/bottom_nav_bar.dart
Normal 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',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
58
lib/core/widgets/ios_toggle.dart
Normal file
58
lib/core/widgets/ios_toggle.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
1
lib/core/widgets/placeholder.dart
Normal file
1
lib/core/widgets/placeholder.dart
Normal file
@@ -0,0 +1 @@
|
||||
// TODO: implement
|
||||
68
lib/core/widgets/prayer_time_card.dart
Normal file
68
lib/core/widgets/prayer_time_card.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
69
lib/core/widgets/progress_bar.dart
Normal file
69
lib/core/widgets/progress_bar.dart
Normal 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.0–1.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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
35
lib/core/widgets/section_header.dart
Normal file
35
lib/core/widgets/section_header.dart
Normal 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!,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user