104 Commits

Author SHA1 Message Date
redz1029 74197c525d Enhanced material design 3 implementation 2026-03-20 15:15:38 +08:00
redz1029 27ebb89052 Added Claude Skills 2026-03-20 15:14:21 +08:00
redz1029 a39e33bc6b Added Team Activity Dashboard 2026-03-18 22:04:15 +08:00
redz1029 4b63b55812 Initial commit for shift push notification reminders 2026-03-18 22:03:50 +08:00
redz1029 3af7a1e348 Added configurable shift types and holiday settings 2026-03-18 16:51:34 +08:00
redz1029 4eaf9444f0 Enhanced Logbook layout 2026-03-18 10:56:31 +08:00
redz1029 84837c4bf2 Show overtime checkin when a user does not have a schedule for the day or the schedule ended for the day 2026-03-18 04:51:45 +08:00
redz1029 3e3e4d560e Ensures allowing assigned IT Staff to check in outside geofence when and IT Service Request venue is outside premise 2026-03-17 07:20:24 +08:00
redz1029 eeab3b1fcf render overtime anytime needed 2026-03-16 19:46:51 +08:00
redz1029 81853c4367 Added programmer role and fixed snackbar not showing 2026-03-16 07:23:20 +08:00
redz1029 9f7791e56f Migration to add leave_of_absence(id) and pass_slips(id) on notifications table 2026-03-15 22:37:42 +08:00
redz1029 885b543fb5 Pass Slip and Leave approval/rejection push notifications 2026-03-15 22:36:33 +08:00
redz1029 6fd3b66251 OTA Updates for adnroid app and web apk uploader 2026-03-15 19:24:34 +08:00
redz1029 9bbaf67fef A more robust self hosted OTA updates implementation 2026-03-13 07:15:28 +08:00
redz1029 9267ebee2c No need update check for web 2026-03-12 20:38:19 +08:00
redz1029 0bd2a94ece OTA Update attempt 2026-03-11 22:28:38 +08:00
redz1029 f8c79acbbc Fixed Leave rejection and approvals 2026-03-11 18:59:28 +08:00
redz1029 21e6d68910 Fixed In Progress ISR not reflecting on IT Staff Pulse Dashboard Status Pill
Made the Location Tracking more persistent
2026-03-11 07:43:14 +08:00
redz1029 24ecca9f06 Background location notification icon and a much more user friendly Location Tracking toggle 2026-03-09 22:57:28 +08:00
redz1029 ccc1c62262 Background location tracking 2026-03-09 22:33:35 +08:00
redz1029 1e1c7d9552 Fixed freezing on start up for some devices 2026-03-09 18:24:44 +08:00
redz1029 b8ec5dbf69 weekend on call shifts 2026-03-09 18:24:16 +08:00
redz1029 aee4604fed Team color read access 2026-03-09 17:52:55 +08:00
redz1029 ac38a21f9a Fixed attendance checkout postgrest unable to choose the best function candidate 2026-03-09 13:37:25 +08:00
redz1029 0f675d4274 Attendance log now record both check in and out photos and allow IT Staffs, Dispatchers and Admins to view for verification 2026-03-08 18:45:31 +08:00
redz1029 f8502f01b6 Whereaboutes and dashboard IT Staff Pulse enhancements 2026-03-08 18:08:03 +08:00
redz1029 9178b438a2 rotation config migration 2026-03-08 17:32:13 +08:00
redz1029 8bf0dc13d7 Workforce rotation settings and location permission handling 2026-03-08 17:31:39 +08:00
redz1029 ce82a88e04 Migrations 2026-03-08 16:58:05 +08:00
redz1029 d87b5e73d7 Team Color, image compression for attendance verification, improved wherebouts 2026-03-08 12:23:28 +08:00
redz1029 a8751ca728 Face Recognition with Liveness Detection for Web Support 2026-03-08 10:19:03 +08:00
redz1029 d3da8901a4 Fixed Shift Labels in Logbook and made Verified/Unverified badge clickable 2026-03-08 08:45:30 +08:00
redz1029 88432551c8 IT Service Request 2026-03-08 07:54:20 +08:00
redz1029 e4391ac465 Minor layout fixes 2026-03-08 04:26:33 +08:00
redz1029 c644143198 Added dependecies image_picker, flutter_liveness_check, google_mlkit_face_detection and qr_flutter 2026-03-07 23:48:20 +08:00
redz1029 4f2fe38c15 platform specific registry 2026-03-07 23:47:18 +08:00
redz1029 3dbebd4006 Attendance validation involving Location Detection + Facial Recoginition with Liveness Detection 2026-03-07 23:46:43 +08:00
redz1029 52ef36faac Used awesome snackbar on attendance_screen 2026-03-07 11:37:37 +08:00
redz1029 c6f536edeb Initial Commit: Duty Schedule and Attendance Logbook 2026-03-07 10:16:28 +08:00
redz1029 73dc735cce M3 Overhaul 2026-03-06 20:03:32 +08:00
redz1029 82fe619f22 Task execution Elapsed timer 2026-03-04 18:42:37 +08:00
redz1029 94088a8796 Implemented Task Pause and Resume 2026-03-04 07:12:00 +08:00
redz1029 81bc618eee Used awesome snackbar and fixed gemini_ico.png not found 2026-03-04 06:44:55 +08:00
redz1029 c123c09233 AI Subject, Description and Action taken support 2026-03-04 00:38:35 +08:00
redz1029 b5449f7842 Still some AI integration issues 2026-03-03 23:35:07 +08:00
redz1029 2e99ec1234 Proper tasks and tickets ordering 2026-03-03 18:52:42 +08:00
redz1029 01c430c812 Implemented subject suggestions in Task Creation and Edit.
Fix editing redirects
2026-03-03 18:51:58 +08:00
redz1029 b1f5d209a2 Implemented subject suggestions in Task Creation and Edit.
Fix editing redirects
2026-03-03 18:39:01 +08:00
redz1029 bfcca47353 Reports migrations 2026-03-03 18:15:45 +08:00
redz1029 1e678ea2e5 Add back button on ticket and task detail screens 2026-03-03 18:15:18 +08:00
redz1029 d9270b3edf Reports 2026-03-03 07:38:40 +08:00
redz1029 7115e2df05 Fix flickering issues due to reconnecting loop 2026-03-02 22:09:29 +08:00
redz1029 5713581992 Push notification tap redirect to corressponding ticket or task 2026-03-02 21:09:38 +08:00
redz1029 eb49329b16 Prevent task starting and completion without assigned it staff 2026-03-02 20:22:42 +08:00
redz1029 43d2bd4f95 Fixed horizontal scrollbar, limit per page by 10 rows on desktop
Fixed duplicate entries in activity logs
2026-03-02 20:11:49 +08:00
redz1029 b9722106ff Task file attachments 2026-03-01 22:11:21 +08:00
redz1029 3950f3ee94 Fixed ghost channel subscription 2026-03-01 20:31:13 +08:00
redz1029 029e671367 Per channel skeleton 2026-03-01 20:10:38 +08:00
redz1029 b9153a070f Fixed migrations 2026-03-01 19:12:13 +08:00
redz1029 830c99a3ff Migrations 2026-03-01 18:51:53 +08:00
redz1029 2100516238 Tickets and task retrieval optimization 2026-03-01 18:27:19 +08:00
redz1029 294d3f7470 Initial implementation of navigation from notification(not yet working) 2026-03-01 17:59:23 +08:00
redz1029 ed078f24ec Track all assigned IT Staff for My Tasks Tab 2026-03-01 17:30:31 +08:00
redz1029 c9479f01f0 Implemented per stream subscription recovery with polling fallback 2026-03-01 17:24:04 +08:00
redz1029 e91e7b43d2 A bit of notification turn on reminder 2026-03-01 05:45:23 +08:00
redz1029 ec46c33c35 Fixed typing provider error 2026-02-28 22:41:19 +08:00
redz1029 d859524a9e Excluded cancelled tasks in dashboards 2026-02-28 22:34:43 +08:00
redz1029 70cdbec5d4 A bit of optimization 2026-02-28 22:27:23 +08:00
redz1029 f7f22c50d2 Moved long running task to an isolate 2026-02-28 22:05:28 +08:00
redz1029 1ce38618f6 Fixed no such method error 2026-02-28 21:51:57 +08:00
redz1029 d3239d8c76 Skeleton loading 2026-02-28 21:47:24 +08:00
redz1029 c5e859ad88 realtime controller 2026-02-28 19:45:21 +08:00
redz1029 9bad41a5ee Fixed IT Job showing without Service 2026-02-28 19:23:34 +08:00
redz1029 e75d61ac64 Wrapped Offices and Services dropdown with consumer to watch if they are already available 2026-02-28 19:12:58 +08:00
redz1029 6882fdcac8 Properly center the icons in Activity logs 2026-02-28 18:14:16 +08:00
redz1029 97d9e83f17 Added cancel details in activity logs as well as used icons 2026-02-28 18:08:45 +08:00
redz1029 e99b87bd20 Activity logs 2026-02-28 18:04:31 +08:00
redz1029 5a74299a1c Added Task Number filter 2026-02-28 16:45:17 +08:00
redz1029 0a8e388757 Move push notification to client instead of db trigger 2026-02-28 16:19:08 +08:00
redz1029 dab43a7f30 A much more detailed notification 2026-02-27 07:05:08 +08:00
redz1029 9cc99e612a Allow background sound notification 2026-02-26 21:43:05 +08:00
redz1029 6ccf820438 FCM push Notifications 2026-02-25 23:37:08 +08:00
redz1029 1807dca57d Ordering of IT Staff in dashboard based on most active 2026-02-25 21:09:10 +08:00
redz1029 db14ec3916 A more robust pdf generation > preview > print workflow 2026-02-25 20:59:21 +08:00
redz1029 3a923ea7f6 Platform specific dependencies 2026-02-25 18:40:22 +08:00
redz1029 aef88de54b No notification permission enforcement on web 2026-02-25 18:39:39 +08:00
redz1029 0173e91d94 Used latest printing dependency 2026-02-25 18:27:12 +08:00
redz1029 22d8b4d778 Set Target SDK 2026-02-25 18:26:50 +08:00
redz1029 eaabc0114c Proper pagination 2026-02-25 18:25:00 +08:00
redz1029 8e8269d12b Fixed task detail scrolling issues on mobile 2026-02-25 07:32:06 +08:00
redz1029 cb71a7ea3a Web Firebase setup 2026-02-24 23:28:18 +08:00
redz1029 7c3a5a1cef Dedicated Assigned to me Tasks Tab 2026-02-24 23:25:27 +08:00
redz1029 546c254326 Allow task completion even if signatories are still empty, put an indicator for completed tasks with incomplete details. 2026-02-24 22:08:53 +08:00
redz1029 892fbee456 Printing not being loaded fix 2026-02-24 21:55:03 +08:00
redz1029 354b27aad1 Added saving indicator when assigning a task 2026-02-24 21:49:25 +08:00
redz1029 5979a04254 * Push Notification Setup and attempt
* Office Ordering
* Allow editing of Task and Ticket Details after creation
2026-02-24 21:06:46 +08:00
redz1029 cc6fda0e79 Allow OS notifications 2026-02-23 22:19:23 +08:00
redz1029 98355c3707 Added validation when task type, category, signatories and action taken are empty upon completing a task 2026-02-23 21:27:55 +08:00
redz1029 1074572905 A better state and alerts 2026-02-23 20:19:07 +08:00
redz1029 0b900d3480 Fixed horizontal scroll 2026-02-23 18:55:56 +08:00
redz1029 ee1bf5e113 Fix height of chat boxes 2026-02-23 18:50:31 +08:00
redz1029 9a0cf7a89d Added migration non admin read only service table 2026-02-23 18:26:25 +08:00
redz1029 3f9f576b33 Improve copilot instructions 2026-02-22 21:04:30 +08:00
redz1029 751cafcf76 Release notes for alpha v0.0.0.1 2026-02-22 21:02:24 +08:00
255 changed files with 50607 additions and 5037 deletions
+8 -2
View File
@@ -1,6 +1,12 @@
{ {
"permissions": {
"allow": [
"Bash(flutter analyze:*)",
"Bash(grep -n \"StreamProvider\\\\|FutureProvider\" /d/tasq/tasq/lib/providers/*.dart)"
]
},
"enableAllProjectMcpServers": true,
"enabledMcpjsonServers": [ "enabledMcpjsonServers": [
"supabase" "supabase"
], ]
"enableAllProjectMcpServers": true
} }
+516
View File
@@ -0,0 +1,516 @@
---
name: custom-plugin-flutter-skill-animations
description: Production-grade Flutter animations mastery - Implicit and explicit animations, AnimationController, Hero transitions, physics-based motion, Lottie/Rive integration, 60fps optimization with comprehensive code examples
sasmp_version: "1.3.0"
bonded_agent: 01-flutter-ui-development
bond_type: PRIMARY_BOND
---
# custom-plugin-flutter: Animations Skill
## Quick Start - Production Animation Pattern
```dart
class AnimatedProductCard extends StatefulWidget {
final Product product;
final bool isSelected;
const AnimatedProductCard({
required this.product,
required this.isSelected,
});
@override
State<AnimatedProductCard> createState() => _AnimatedProductCardState();
}
class _AnimatedProductCardState extends State<AnimatedProductCard>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late final Animation<double> _scaleAnimation;
late final Animation<double> _opacityAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 200),
vsync: this,
);
_scaleAnimation = Tween<double>(begin: 1.0, end: 0.95).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
);
_opacityAnimation = Tween<double>(begin: 1.0, end: 0.8).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _onTapDown(TapDownDetails details) => _controller.forward();
void _onTapUp(TapUpDetails details) => _controller.reverse();
void _onTapCancel() => _controller.reverse();
@override
Widget build(BuildContext context) {
return GestureDetector(
onTapDown: _onTapDown,
onTapUp: _onTapUp,
onTapCancel: _onTapCancel,
child: AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Transform.scale(
scale: _scaleAnimation.value,
child: Opacity(
opacity: _opacityAnimation.value,
child: child,
),
);
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
decoration: BoxDecoration(
border: Border.all(
color: widget.isSelected ? Colors.blue : Colors.grey,
width: widget.isSelected ? 2 : 1,
),
borderRadius: BorderRadius.circular(12),
),
child: ProductContent(product: widget.product),
),
),
);
}
}
```
## 1. Implicit Animations
### Built-in Animated Widgets
```dart
// AnimatedContainer - Animate multiple properties
AnimatedContainer(
duration: Duration(milliseconds: 300),
curve: Curves.easeInOut,
width: isExpanded ? 300 : 100,
height: isExpanded ? 200 : 100,
decoration: BoxDecoration(
color: isActive ? Colors.blue : Colors.grey,
borderRadius: BorderRadius.circular(isExpanded ? 16 : 8),
boxShadow: isActive ? [BoxShadow(blurRadius: 10)] : [],
),
child: content,
)
// AnimatedOpacity
AnimatedOpacity(
opacity: isVisible ? 1.0 : 0.0,
duration: Duration(milliseconds: 200),
child: content,
)
// AnimatedPositioned (inside Stack)
AnimatedPositioned(
duration: Duration(milliseconds: 300),
left: isLeft ? 0 : 100,
top: isTop ? 0 : 100,
child: widget,
)
// AnimatedSwitcher - Cross-fade between widgets
AnimatedSwitcher(
duration: Duration(milliseconds: 300),
transitionBuilder: (child, animation) {
return FadeTransition(opacity: animation, child: child);
},
child: Text(
currentText,
key: ValueKey(currentText), // Important!
),
)
// TweenAnimationBuilder - Custom implicit animation
TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: progress),
duration: Duration(milliseconds: 500),
builder: (context, value, child) {
return CircularProgressIndicator(value: value);
},
)
```
## 2. Explicit Animations
### AnimationController Pattern
```dart
class ExplicitAnimationWidget extends StatefulWidget {
@override
State<ExplicitAnimationWidget> createState() => _ExplicitAnimationWidgetState();
}
class _ExplicitAnimationWidgetState extends State<ExplicitAnimationWidget>
with TickerProviderStateMixin {
late final AnimationController _controller;
late final Animation<Offset> _slideAnimation;
late final Animation<double> _fadeAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: Duration(milliseconds: 500),
vsync: this,
);
_slideAnimation = Tween<Offset>(
begin: Offset(0, 1),
end: Offset.zero,
).animate(CurvedAnimation(
parent: _controller,
curve: Interval(0.0, 0.6, curve: Curves.easeOut),
));
_fadeAnimation = Tween<double>(
begin: 0,
end: 1,
).animate(CurvedAnimation(
parent: _controller,
curve: Interval(0.3, 1.0, curve: Curves.easeIn),
));
_controller.forward();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SlideTransition(
position: _slideAnimation,
child: FadeTransition(
opacity: _fadeAnimation,
child: Content(),
),
);
}
}
```
### Animation Transitions
```dart
// Built-in transitions
FadeTransition(opacity: animation, child: widget)
SlideTransition(position: offsetAnimation, child: widget)
ScaleTransition(scale: animation, child: widget)
RotationTransition(turns: animation, child: widget)
SizeTransition(sizeFactor: animation, child: widget)
// AnimatedBuilder - Custom rendering
AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Transform(
transform: Matrix4.identity()
..setEntry(3, 2, 0.001)
..rotateY(_controller.value * pi),
alignment: Alignment.center,
child: child,
);
},
child: Card(),
)
```
## 3. Hero Animations
```dart
// Source screen
Hero(
tag: 'product-${product.id}',
child: Image.network(product.imageUrl),
)
// Destination screen
Hero(
tag: 'product-${product.id}',
child: Image.network(product.imageUrl),
)
// Custom Hero flight
Hero(
tag: 'avatar',
flightShuttleBuilder: (
flightContext,
animation,
flightDirection,
fromHeroContext,
toHeroContext,
) {
return AnimatedBuilder(
animation: animation,
builder: (context, _) {
return CircleAvatar(
radius: lerpDouble(40, 60, animation.value),
);
},
);
},
child: CircleAvatar(),
)
```
## 4. Staggered Animations
```dart
class StaggeredList extends StatefulWidget {
@override
State<StaggeredList> createState() => _StaggeredListState();
}
class _StaggeredListState extends State<StaggeredList>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
final List<Animation<Offset>> _animations = [];
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: Duration(milliseconds: 1000),
vsync: this,
);
// Create staggered animations
for (int i = 0; i < 5; i++) {
final start = i * 0.1;
final end = start + 0.4;
_animations.add(
Tween<Offset>(begin: Offset(1, 0), end: Offset.zero).animate(
CurvedAnimation(
parent: _controller,
curve: Interval(start, end, curve: Curves.easeOut),
),
),
);
}
_controller.forward();
}
@override
Widget build(BuildContext context) {
return Column(
children: List.generate(5, (index) {
return SlideTransition(
position: _animations[index],
child: ListTile(title: Text('Item $index')),
);
}),
);
}
}
```
## 5. Physics-Based Animations
```dart
// Spring simulation
class SpringAnimation extends StatefulWidget {
@override
State<SpringAnimation> createState() => _SpringAnimationState();
}
class _SpringAnimationState extends State<SpringAnimation>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late Animation<double> _animation;
final SpringDescription spring = SpringDescription(
mass: 1,
stiffness: 100,
damping: 10,
);
@override
void initState() {
super.initState();
_controller = AnimationController(vsync: this);
final simulation = SpringSimulation(spring, 0, 1, 0);
_controller.animateWith(simulation);
_animation = _controller.drive(Tween(begin: 0.0, end: 100.0));
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _animation,
builder: (context, child) {
return Transform.translate(
offset: Offset(0, _animation.value),
child: child,
);
},
child: Ball(),
);
}
}
```
## 6. Lottie & Rive Integration
```dart
// Lottie animation
import 'package:lottie/lottie.dart';
Lottie.asset(
'assets/loading.json',
width: 200,
height: 200,
fit: BoxFit.contain,
repeat: true,
animate: true,
onLoaded: (composition) {
_controller.duration = composition.duration;
},
)
// Rive animation
import 'package:rive/rive.dart';
RiveAnimation.asset(
'assets/animation.riv',
fit: BoxFit.cover,
stateMachines: ['StateMachine1'],
onInit: (artboard) {
final controller = StateMachineController.fromArtboard(
artboard,
'StateMachine1',
);
artboard.addController(controller!);
_trigger = controller.findInput<bool>('trigger') as SMITrigger;
},
)
```
## 7. Page Transitions
```dart
// Custom page route
class FadePageRoute<T> extends PageRouteBuilder<T> {
final Widget page;
FadePageRoute({required this.page})
: super(
pageBuilder: (context, animation, secondaryAnimation) => page,
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return FadeTransition(opacity: animation, child: child);
},
transitionDuration: Duration(milliseconds: 300),
);
}
// Slide + fade combined
transitionsBuilder: (context, animation, secondaryAnimation, child) {
final offsetAnimation = Tween<Offset>(
begin: Offset(1.0, 0.0),
end: Offset.zero,
).animate(CurvedAnimation(
parent: animation,
curve: Curves.easeInOut,
));
return SlideTransition(
position: offsetAnimation,
child: FadeTransition(
opacity: animation,
child: child,
),
);
}
```
## 8. Performance Optimization
```dart
// Use const where possible
const AnimatedContainer(...)
// RepaintBoundary for expensive animations
RepaintBoundary(
child: AnimatedWidget(),
)
// AnimatedBuilder isolates rebuilds
AnimatedBuilder(
animation: _controller,
child: ExpensiveWidget(), // Not rebuilt
builder: (context, child) {
return Transform.scale(
scale: _controller.value,
child: child, // Reused
);
},
)
// Avoid layout-triggering animations
// Good: Transform, Opacity
// Avoid: width/height in tight loops
```
## Troubleshooting Guide
**Issue: Animation jank (dropped frames)**
```
1. Use RepaintBoundary to isolate
2. Profile with DevTools Performance
3. Avoid layout changes during animation
4. Use AnimatedBuilder, not setState
5. Check for heavy build methods
```
**Issue: Animation doesn't start**
```
1. Verify AnimationController.forward() called
2. Check vsync: this (TickerProviderStateMixin)
3. Verify widget is mounted before animating
4. Check duration is set
```
**Issue: Animation leaks memory**
```
1. Dispose AnimationController in dispose()
2. Cancel any listeners
3. Use mounted check before setState
```
## Animation Selection Guide
| Need | Solution |
|------|----------|
| Simple property change | AnimatedContainer |
| Cross-fade widgets | AnimatedSwitcher |
| Custom timing control | AnimationController |
| Shared element | Hero |
| List items appearing | Staggered animations |
| Natural motion | Physics simulation |
| Complex vector | Lottie/Rive |
---
**Create fluid, 60fps animations in Flutter.**
@@ -0,0 +1,41 @@
# animations Configuration
# Category: general
# Generated: 2025-12-30
skill:
name: animations
version: "1.0.0"
category: general
settings:
# Default settings for animations
enabled: true
log_level: info
# Category-specific defaults
validation:
strict_mode: false
auto_fix: false
output:
format: markdown
include_examples: true
# Environment-specific overrides
environments:
development:
log_level: debug
validation:
strict_mode: false
production:
log_level: warn
validation:
strict_mode: true
# Integration settings
integrations:
# Enable/disable integrations
git: true
linter: true
formatter: true
@@ -0,0 +1,60 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "animations Configuration Schema",
"type": "object",
"properties": {
"skill": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"version": {
"type": "string",
"pattern": "^\\d+\\.\\d+\\.\\d+$"
},
"category": {
"type": "string",
"enum": [
"api",
"testing",
"devops",
"security",
"database",
"frontend",
"algorithms",
"machine-learning",
"cloud",
"containers",
"general"
]
}
},
"required": [
"name",
"version"
]
},
"settings": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean",
"default": true
},
"log_level": {
"type": "string",
"enum": [
"debug",
"info",
"warn",
"error"
]
}
}
}
},
"required": [
"skill"
]
}
@@ -0,0 +1,95 @@
# Animations Guide
## Overview
This guide provides comprehensive documentation for the **animations** skill in the custom-plugin-flutter plugin.
## Category: General
## Quick Start
### Prerequisites
- Familiarity with general concepts
- Development environment set up
- Plugin installed and configured
### Basic Usage
```bash
# Invoke the skill
claude "animations - [your task description]"
# Example
claude "animations - analyze the current implementation"
```
## Core Concepts
### Key Principles
1. **Consistency** - Follow established patterns
2. **Clarity** - Write readable, maintainable code
3. **Quality** - Validate before deployment
### Best Practices
- Always validate input data
- Handle edge cases explicitly
- Document your decisions
- Write tests for critical paths
## Common Tasks
### Task 1: Basic Implementation
```python
# Example implementation pattern
def implement_animations(input_data):
"""
Implement animations functionality.
Args:
input_data: Input to process
Returns:
Processed result
"""
# Validate input
if not input_data:
raise ValueError("Input required")
# Process
result = process(input_data)
# Return
return result
```
### Task 2: Advanced Usage
For advanced scenarios, consider:
- Configuration customization via `assets/config.yaml`
- Validation using `scripts/validate.py`
- Integration with other skills
## Troubleshooting
### Common Issues
| Issue | Cause | Solution |
|-------|-------|----------|
| Skill not found | Not installed | Run plugin sync |
| Validation fails | Invalid config | Check config.yaml |
| Unexpected output | Missing context | Provide more details |
## Related Resources
- SKILL.md - Skill specification
- config.yaml - Configuration options
- validate.py - Validation script
---
*Last updated: 2025-12-30*
@@ -0,0 +1,87 @@
# Animations Patterns
## Design Patterns
### Pattern 1: Input Validation
Always validate input before processing:
```python
def validate_input(data):
if data is None:
raise ValueError("Data cannot be None")
if not isinstance(data, dict):
raise TypeError("Data must be a dictionary")
return True
```
### Pattern 2: Error Handling
Use consistent error handling:
```python
try:
result = risky_operation()
except SpecificError as e:
logger.error(f"Operation failed: {e}")
handle_error(e)
except Exception as e:
logger.exception("Unexpected error")
raise
```
### Pattern 3: Configuration Loading
Load and validate configuration:
```python
import yaml
def load_config(config_path):
with open(config_path) as f:
config = yaml.safe_load(f)
validate_config(config)
return config
```
## Anti-Patterns to Avoid
### ❌ Don't: Swallow Exceptions
```python
# BAD
try:
do_something()
except:
pass
```
### ✅ Do: Handle Explicitly
```python
# GOOD
try:
do_something()
except SpecificError as e:
logger.warning(f"Expected error: {e}")
return default_value
```
## Category-Specific Patterns: General
### Recommended Approach
1. Start with the simplest implementation
2. Add complexity only when needed
3. Test each addition
4. Document decisions
### Common Integration Points
- Configuration: `assets/config.yaml`
- Validation: `scripts/validate.py`
- Documentation: `references/GUIDE.md`
---
*Pattern library for animations skill*
@@ -0,0 +1,131 @@
#!/usr/bin/env python3
"""
Validation script for animations skill.
Category: general
"""
import os
import sys
import yaml
import json
from pathlib import Path
def validate_config(config_path: str) -> dict:
"""
Validate skill configuration file.
Args:
config_path: Path to config.yaml
Returns:
dict: Validation result with 'valid' and 'errors' keys
"""
errors = []
if not os.path.exists(config_path):
return {"valid": False, "errors": ["Config file not found"]}
try:
with open(config_path, 'r') as f:
config = yaml.safe_load(f)
except yaml.YAMLError as e:
return {"valid": False, "errors": [f"YAML parse error: {e}"]}
# Validate required fields
if 'skill' not in config:
errors.append("Missing 'skill' section")
else:
if 'name' not in config['skill']:
errors.append("Missing skill.name")
if 'version' not in config['skill']:
errors.append("Missing skill.version")
# Validate settings
if 'settings' in config:
settings = config['settings']
if 'log_level' in settings:
valid_levels = ['debug', 'info', 'warn', 'error']
if settings['log_level'] not in valid_levels:
errors.append(f"Invalid log_level: {settings['log_level']}")
return {
"valid": len(errors) == 0,
"errors": errors,
"config": config if not errors else None
}
def validate_skill_structure(skill_path: str) -> dict:
"""
Validate skill directory structure.
Args:
skill_path: Path to skill directory
Returns:
dict: Structure validation result
"""
required_dirs = ['assets', 'scripts', 'references']
required_files = ['SKILL.md']
errors = []
# Check required files
for file in required_files:
if not os.path.exists(os.path.join(skill_path, file)):
errors.append(f"Missing required file: {file}")
# Check required directories
for dir in required_dirs:
dir_path = os.path.join(skill_path, dir)
if not os.path.isdir(dir_path):
errors.append(f"Missing required directory: {dir}/")
else:
# Check for real content (not just .gitkeep)
files = [f for f in os.listdir(dir_path) if f != '.gitkeep']
if not files:
errors.append(f"Directory {dir}/ has no real content")
return {
"valid": len(errors) == 0,
"errors": errors,
"skill_name": os.path.basename(skill_path)
}
def main():
"""Main validation entry point."""
skill_path = Path(__file__).parent.parent
print(f"Validating animations skill...")
print(f"Path: {skill_path}")
# Validate structure
structure_result = validate_skill_structure(str(skill_path))
print(f"\nStructure validation: {'PASS' if structure_result['valid'] else 'FAIL'}")
if structure_result['errors']:
for error in structure_result['errors']:
print(f" - {error}")
# Validate config
config_path = skill_path / 'assets' / 'config.yaml'
if config_path.exists():
config_result = validate_config(str(config_path))
print(f"\nConfig validation: {'PASS' if config_result['valid'] else 'FAIL'}")
if config_result['errors']:
for error in config_result['errors']:
print(f" - {error}")
else:
print("\nConfig validation: SKIPPED (no config.yaml)")
# Summary
all_valid = structure_result['valid']
print(f"\n==================================================")
print(f"Overall: {'VALID' if all_valid else 'INVALID'}")
return 0 if all_valid else 1
if __name__ == "__main__":
sys.exit(main())
+39
View File
@@ -0,0 +1,39 @@
---
name: deepsearch
description: Thorough codebase search
user-invocable: true
---
# Deep Search Mode
[DEEPSEARCH MODE ACTIVATED]
## Objective
Perform thorough search of the codebase for the specified query, pattern, or concept.
## Search Strategy
1. **Broad Search**
- Search for exact matches
- Search for related terms and variations
- Check common locations (components, utils, services, hooks)
2. **Deep Dive**
- Read files with matches
- Check imports/exports to find connections
- Follow the trail (what imports this? what does this import?)
3. **Synthesize**
- Map out where the concept is used
- Identify the main implementation
- Note related functionality
## Output Format
- **Primary Locations** (main implementations)
- **Related Files** (dependencies, consumers)
- **Usage Patterns** (how it's used across the codebase)
- **Key Insights** (patterns, conventions, gotchas)
Focus on being comprehensive but concise. Cite file paths and line numbers.
+316
View File
@@ -0,0 +1,316 @@
---
name: flutter-development
description: Build beautiful cross-platform mobile apps with Flutter and Dart. Covers widgets, state management with Provider/BLoC, navigation, API integration, and material design.
---
# Flutter Development
## Overview
Create high-performance, visually stunning mobile applications using Flutter with Dart language. Master widget composition, state management patterns, navigation, and API integration.
## When to Use
- Building iOS and Android apps with native performance
- Designing custom UIs with Flutter's widget system
- Implementing complex animations and visual effects
- Rapid app development with hot reload
- Creating consistent UX across platforms
## Instructions
### 1. **Project Structure & Navigation**
```dart
// pubspec.yaml
name: my_flutter_app
version: 1.0.0
dependencies:
flutter:
sdk: flutter
provider: ^6.0.0
http: ^1.1.0
go_router: ^12.0.0
// main.dart with GoRouter navigation
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp.router(
title: 'Flutter App',
theme: ThemeData(primarySwatch: Colors.blue, useMaterial3: true),
routerConfig: _router,
);
}
}
final GoRouter _router = GoRouter(
routes: <RouteBase>[
GoRoute(
path: '/',
builder: (context, state) => const HomeScreen(),
routes: [
GoRoute(
path: 'details/:id',
builder: (context, state) => DetailsScreen(
itemId: state.pathParameters['id']!
),
),
],
),
GoRoute(
path: '/profile',
builder: (context, state) => const ProfileScreen(),
),
],
);
```
### 2. **State Management with Provider**
```dart
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'dart:convert';
class User {
final String id;
final String name;
final String email;
User({required this.id, required this.name, required this.email});
factory User.fromJson(Map<String, dynamic> json) {
return User(
id: json['id'],
name: json['name'],
email: json['email'],
);
}
}
class UserProvider extends ChangeNotifier {
User? _user;
bool _isLoading = false;
String? _error;
User? get user => _user;
bool get isLoading => _isLoading;
String? get error => _error;
Future<void> fetchUser(String userId) async {
_isLoading = true;
_error = null;
notifyListeners();
try {
final response = await http.get(
Uri.parse('https://api.example.com/users/$userId'),
headers: {'Content-Type': 'application/json'},
);
if (response.statusCode == 200) {
_user = User.fromJson(jsonDecode(response.body));
} else {
_error = 'Failed to fetch user';
}
} catch (e) {
_error = 'Error: ${e.toString()}';
} finally {
_isLoading = false;
notifyListeners();
}
}
void logout() {
_user = null;
notifyListeners();
}
}
class ItemsProvider extends ChangeNotifier {
List<Map<String, dynamic>> _items = [];
List<Map<String, dynamic>> get items => _items;
Future<void> fetchItems() async {
try {
final response = await http.get(
Uri.parse('https://api.example.com/items'),
);
if (response.statusCode == 200) {
_items = List<Map<String, dynamic>>.from(
jsonDecode(response.body) as List
);
notifyListeners();
}
} catch (e) {
print('Error fetching items: $e');
}
}
}
```
### 3. **Screens with Provider Integration**
```dart
class HomeScreen extends StatefulWidget {
const HomeScreen({Key? key}) : super(key: key);
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
@override
void initState() {
super.initState();
Future.microtask(() {
Provider.of<ItemsProvider>(context, listen: false).fetchItems();
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Home Feed')),
body: Consumer<ItemsProvider>(
builder: (context, itemsProvider, child) {
if (itemsProvider.items.isEmpty) {
return const Center(child: Text('No items found'));
}
return ListView.builder(
itemCount: itemsProvider.items.length,
itemBuilder: (context, index) {
final item = itemsProvider.items[index];
return ItemCard(item: item);
},
);
},
),
);
}
}
class ItemCard extends StatelessWidget {
final Map<String, dynamic> item;
const ItemCard({required this.item, Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.all(8),
child: ListTile(
title: Text(item['title'] ?? 'Untitled'),
subtitle: Text(item['description'] ?? ''),
trailing: const Icon(Icons.arrow_forward),
onTap: () => context.go('/details/${item['id']}'),
),
);
}
}
class DetailsScreen extends StatelessWidget {
final String itemId;
const DetailsScreen({required this.itemId, Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Details')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Item ID: $itemId', style: const TextStyle(fontSize: 18)),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => context.pop(),
child: const Text('Go Back'),
),
],
),
),
);
}
}
class ProfileScreen extends StatelessWidget {
const ProfileScreen({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Profile')),
body: Consumer<UserProvider>(
builder: (context, userProvider, child) {
if (userProvider.isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (userProvider.error != null) {
return Center(child: Text('Error: ${userProvider.error}'));
}
final user = userProvider.user;
if (user == null) {
return const Center(child: Text('No user data'));
}
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Name: ${user.name}', style: const TextStyle(fontSize: 18)),
Text('Email: ${user.email}', style: const TextStyle(fontSize: 16)),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => userProvider.logout(),
child: const Text('Logout'),
),
],
),
);
},
),
);
}
}
```
## Best Practices
### ✅ DO
- Use widgets for every UI element
- Implement proper state management
- Use const constructors where possible
- Dispose resources in state lifecycle
- Test on multiple device sizes
- Use meaningful widget names
- Implement error handling
- Use responsive design patterns
- Test on both iOS and Android
- Document custom widgets
### ❌ DON'T
- Build entire screens in build() method
- Use setState for complex state logic
- Make network calls in build()
- Ignore platform differences
- Create overly nested widget trees
- Hardcode strings
- Ignore performance warnings
- Skip testing
- Forget to handle edge cases
- Deploy without thorough testing
@@ -0,0 +1,14 @@
{
"id": "aj-geddes-useful-ai-prompts-skills-flutter-development-skill-md",
"name": "flutter-development",
"author": "aj-geddes",
"authorAvatar": "https://avatars.githubusercontent.com/u/211219442?v=4",
"description": "Build beautiful cross-platform mobile apps with Flutter and Dart. Covers widgets, state management with Provider/BLoC, navigation, API integration, and material design.",
"githubUrl": "https://github.com/aj-geddes/useful-ai-prompts/tree/main/skills/flutter-development",
"stars": 12,
"forks": 0,
"updatedAt": 1764499235,
"hasMarketplace": false,
"path": "SKILL.md",
"branch": "main"
}
+614
View File
@@ -0,0 +1,614 @@
---
name: custom-plugin-flutter-skill-ui
description: 1700+ lines of Flutter UI mastery - widgets, layouts, Material Design, animations, responsive design with production-ready code examples and enterprise patterns.
sasmp_version: "1.3.0"
bonded_agent: 01-flutter-ui-development
bond_type: PRIMARY_BOND
---
# custom-plugin-flutter: UI Development Skill
## Quick Start - Production UI Pattern
```dart
class ResponsiveProductScreen extends ConsumerWidget {
const ResponsiveProductScreen({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
final isMobile = MediaQuery.of(context).size.width < 600;
final productState = ref.watch(productProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Products'),
elevation: 0,
),
body: productState.when(
loading: () => const LoadingWidget(),
data: (products) => isMobile
? _MobileProductList(products: products)
: _DesktopProductGrid(products: products),
error: (error, st) => ErrorWidget(error: error.toString()),
),
);
}
}
class _MobileProductList extends StatelessWidget {
final List<Product> products;
const _MobileProductList({required this.products});
@override
Widget build(BuildContext context) {
return ListView.builder(
itemCount: products.length,
itemBuilder: (context, index) => ProductCard(product: products[index]),
);
}
}
class _DesktopProductGrid extends StatelessWidget {
final List<Product> products;
const _DesktopProductGrid({required this.products});
@override
Widget build(BuildContext context) {
return GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
childAspectRatio: 0.8,
),
itemCount: products.length,
itemBuilder: (context, index) => ProductCard(product: products[index]),
);
}
}
```
## 1. Widget System Mastery
### Understanding the Widget Tree
**Stateless Widgets** - Pure, immutable widgets:
```dart
class PureWidget extends StatelessWidget {
final String title;
const PureWidget({required this.title});
@override
Widget build(BuildContext context) => Text(title);
}
```
**Stateful Widgets** - Managing internal state:
```dart
class CounterWidget extends StatefulWidget {
const CounterWidget({Key? key}) : super(key: key);
@override
State<CounterWidget> createState() => _CounterWidgetState();
}
class _CounterWidgetState extends State<CounterWidget> {
int _count = 0;
void _increment() {
setState(() => _count++);
}
@override
void initState() {
super.initState();
// Initialize resources
}
@override
void dispose() {
// Clean up resources
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
children: [
Text('Count: $_count'),
ElevatedButton(
onPressed: _increment,
child: const Text('Increment'),
),
],
);
}
}
```
**Const Constructors** - Preventing unnecessary rebuilds:
```dart
// ✅ Good - Const constructor
const SizedBox(height: 16, child: Text('Hello'))
// ❌ Bad - Non-const, rebuilds every time
SizedBox(height: 16, child: Text('Hello'))
// ✅ Make all widgets const when possible
class MyWidget extends StatelessWidget {
const MyWidget({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) => const Placeholder();
}
```
**Inherited Widgets** - Efficient state propagation:
```dart
class ThemeProvider extends InheritedWidget {
final ThemeData theme;
const ThemeProvider({
required this.theme,
required super.child,
super.key,
});
static ThemeProvider of(BuildContext context) {
final result = context.dependOnInheritedWidgetOfExactType<ThemeProvider>();
assert(result != null, 'No ThemeProvider found in context');
return result!;
}
@override
bool updateShouldNotify(ThemeProvider oldWidget) => theme != oldWidget.theme;
}
// Usage
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ThemeProvider(
theme: ThemeData.light(),
child: MaterialApp(home: MyScreen()),
);
}
}
class MyScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = ThemeProvider.of(context).theme;
return Scaffold(
backgroundColor: theme.scaffoldBackgroundColor,
);
}
}
```
## 2. Constraint-Based Layout System
### Understanding Constraints
**Basic Constraint Flow**:
```dart
// Parent imposes constraints on children
// Child sizes itself based on constraints
// Parent positions child based on alignment
Center( // Imposes tight constraint
child: Container(
width: 200,
height: 100,
color: Colors.blue,
),
)
```
**Layout Widgets**:
```dart
// Row - horizontal layout
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text('Left'),
Text('Middle'),
Text('Right'),
],
)
// Column - vertical layout
Column(
mainAxisSize: MainAxisSize.max,
children: [
Container(height: 100, color: Colors.red),
Container(height: 100, color: Colors.blue),
Container(height: 100, color: Colors.green),
],
)
// Flex - flexible layout
Flex(
direction: Axis.horizontal,
children: [
Flexible(flex: 2, child: Container(color: Colors.red)),
Flexible(flex: 1, child: Container(color: Colors.blue)),
],
)
```
**Advanced Layouts**:
```dart
// GridView - grid layout
GridView.builder(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
),
itemCount: 12,
itemBuilder: (context, index) => Container(
color: Colors.blue[100 * ((index % 9) + 1)],
),
)
// Stack - overlaying widgets
Stack(
alignment: Alignment.bottomRight,
children: [
Image.asset('background.png'),
Positioned(
right: 16,
bottom: 16,
child: FloatingActionButton(onPressed: () {}),
),
],
)
// CustomMultiChildLayout - manual layout
CustomMultiChildLayout(
delegate: MyLayoutDelegate(),
children: [
LayoutId(id: 'title', child: Text('Title')),
LayoutId(id: 'body', child: Text('Body')),
],
)
```
## 3. Material Design 3 Implementation
### Theme Configuration
```dart
class AppTheme {
static ThemeData lightTheme = ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.blue,
brightness: Brightness.light,
),
typography: Typography.material2021(
platform: defaultTargetPlatform,
),
appBarTheme: AppBarTheme(
elevation: 0,
backgroundColor: Colors.transparent,
),
cardTheme: CardTheme(
elevation: 2,
margin: EdgeInsets.all(16),
),
);
static ThemeData darkTheme = ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.blue,
brightness: Brightness.dark,
),
);
}
// Usage
MaterialApp(
theme: AppTheme.lightTheme,
darkTheme: AppTheme.darkTheme,
themeMode: ThemeMode.system,
)
```
### Material Components
```dart
// Modern AppBar
AppBar(
title: Text('Title'),
elevation: 0,
backgroundColor: Theme.of(context).colorScheme.surface,
foregroundColor: Theme.of(context).colorScheme.onSurface,
)
// Enhanced Button
ElevatedButton.icon(
onPressed: () {},
icon: Icon(Icons.send),
label: Text('Send'),
style: ElevatedButton.styleFrom(
padding: EdgeInsets.symmetric(horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
)
// Material TextField
TextField(
decoration: InputDecoration(
labelText: 'Enter name',
hintText: 'John Doe',
prefixIcon: Icon(Icons.person),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
filled: true,
fillColor: Colors.grey[100],
),
)
// Material Form
Form(
key: _formKey,
child: Column(
children: [
TextFormField(
validator: (value) {
if (value?.isEmpty ?? true) return 'Required';
return null;
},
),
ElevatedButton(
onPressed: () {
if (_formKey.currentState!.validate()) {
// Submit form
}
},
child: Text('Submit'),
),
],
),
)
```
## 4. Animation Framework
### AnimationController Pattern
```dart
class AnimatedCounterWidget extends StatefulWidget {
const AnimatedCounterWidget();
@override
State<AnimatedCounterWidget> createState() => _AnimatedCounterWidgetState();
}
class _AnimatedCounterWidgetState extends State<AnimatedCounterWidget>
with TickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
int _count = 0;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: Duration(milliseconds: 300),
vsync: this,
);
_animation = Tween<double>(begin: 1, end: 0.8).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
);
}
void _increment() {
setState(() => _count++);
_controller.forward().then((_) => _controller.reverse());
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ScaleTransition(
scale: _animation,
child: GestureDetector(
onTap: _increment,
child: Container(
width: 100,
height: 100,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.blue,
),
child: Center(
child: Text(
'$_count',
style: Theme.of(context).textTheme.headlineLarge?.copyWith(
color: Colors.white,
),
),
),
),
),
);
}
}
```
### Implicit Animations
```dart
// AnimatedContainer - animate properties smoothly
class AnimatedBoxWidget extends StatefulWidget {
@override
State<AnimatedBoxWidget> createState() => _AnimatedBoxWidgetState();
}
class _AnimatedBoxWidgetState extends State<AnimatedBoxWidget> {
bool _expanded = false;
@override
Widget build(BuildContext context) {
return Center(
child: GestureDetector(
onTap: () => setState(() => _expanded = !_expanded),
child: AnimatedContainer(
duration: Duration(milliseconds: 500),
width: _expanded ? 300 : 100,
height: _expanded ? 300 : 100,
decoration: BoxDecoration(
color: _expanded ? Colors.blue : Colors.red,
borderRadius: BorderRadius.circular(_expanded ? 16 : 8),
),
child: Center(
child: AnimatedOpacity(
opacity: _expanded ? 1 : 0,
duration: Duration(milliseconds: 500),
child: Text('Expanded'),
),
),
),
),
);
}
}
```
## 5. Responsive Design Pattern
```dart
class ResponsiveWidget extends StatelessWidget {
final Widget Function(BuildContext) mobileBuilder;
final Widget Function(BuildContext) tabletBuilder;
final Widget Function(BuildContext) desktopBuilder;
const ResponsiveWidget({
required this.mobileBuilder,
required this.tabletBuilder,
required this.desktopBuilder,
});
@override
Widget build(BuildContext context) {
final width = MediaQuery.of(context).size.width;
if (width < 600) {
return mobileBuilder(context);
} else if (width < 1200) {
return tabletBuilder(context);
} else {
return desktopBuilder(context);
}
}
}
// Usage
ResponsiveWidget(
mobileBuilder: (context) => MobileLayout(),
tabletBuilder: (context) => TabletLayout(),
desktopBuilder: (context) => DesktopLayout(),
)
```
## 6. Accessibility Best Practices
```dart
class AccessibleWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
label: 'Submit form',
enabled: true,
onTap: () {},
child: Container(
constraints: BoxConstraints(minHeight: 48, minWidth: 48),
decoration: BoxDecoration(
color: Colors.blue,
borderRadius: BorderRadius.circular(8),
),
child: Center(
child: Text(
'Submit',
style: TextStyle(fontSize: 16), // Respect system font scaling
),
),
),
);
}
}
```
## 7. Performance Optimization Tips
- ✅ Use `const` constructors everywhere possible
- ✅ Extract widgets to separate classes to limit rebuilds
- ✅ Use `RepaintBoundary` for expensive rendering
- ✅ Use `ListView.builder` instead of `ListView`
- ✅ Cache images with `CachedNetworkImage`
- ✅ Profile with DevTools regularly
- ✅ Use `LayoutBuilder` for responsive design
- ✅ Prefer `SingleChildScrollView` over custom scrolling
## 8. Custom Widget Template
```dart
class CustomButton extends StatelessWidget {
final VoidCallback onPressed;
final String label;
final ButtonSize size;
const CustomButton({
required this.onPressed,
required this.label,
this.size = ButtonSize.medium,
});
@override
Widget build(BuildContext context) {
return Material(
child: InkWell(
onTap: onPressed,
child: Container(
padding: _paddingForSize(size),
decoration: BoxDecoration(
color: Theme.of(context).primaryColor,
borderRadius: BorderRadius.circular(8),
),
child: Text(
label,
style: Theme.of(context).textTheme.labelLarge,
),
),
),
);
}
EdgeInsets _paddingForSize(ButtonSize size) {
switch (size) {
case ButtonSize.small:
return EdgeInsets.symmetric(horizontal: 12, vertical: 8);
case ButtonSize.medium:
return EdgeInsets.symmetric(horizontal: 16, vertical: 12);
case ButtonSize.large:
return EdgeInsets.symmetric(horizontal: 24, vertical: 16);
}
}
}
enum ButtonSize { small, medium, large }
```
---
**Master Flutter UI development with this comprehensive skill reference.**
@@ -0,0 +1,20 @@
widgets:
stateless:
- Container
- Text
- Icon
- Image
stateful:
- TextField
- ListView
- AnimatedWidget
layouts:
- Column
- Row
- Stack
- GridView
- CustomScrollView
theming:
- material_3
- cupertino
- custom_theme
@@ -0,0 +1,12 @@
# Flutter UI Guide
## Widget System
- Everything is a widget
- Composition over inheritance
- StatelessWidget for static UI
- StatefulWidget for dynamic UI
## Layout Widgets
- Column/Row: Linear layout
- Stack: Overlapping
- ListView: Scrollable lists
- GridView: Grid layouts
@@ -0,0 +1,5 @@
#!/usr/bin/env python3
"""Flutter widget analyzer."""
import json
def analyze(): return {"categories": ["stateless", "stateful", "inherited", "render"], "layouts": ["flex", "stack", "custom"]}
if __name__ == "__main__": print(json.dumps(analyze(), indent=2))
+42
View File
@@ -0,0 +1,42 @@
---
name: frontend-design
description: Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, artifacts, posters, or applications (examples include websites, landing pages, dashboards, React components, HTML/CSS layouts, or when styling/beautifying any web UI). Generates creative, polished code and UI design that avoids generic AI aesthetics.
license: Complete terms in LICENSE.txt
---
This skill guides creation of distinctive, production-grade frontend interfaces that avoid generic "AI slop" aesthetics. Implement real working code with exceptional attention to aesthetic details and creative choices.
The user provides frontend requirements: a component, page, application, or interface to build. They may include context about the purpose, audience, or technical constraints.
## Design Thinking
Before coding, understand the context and commit to a BOLD aesthetic direction:
- **Purpose**: What problem does this interface solve? Who uses it?
- **Tone**: Pick an extreme: brutally minimal, maximalist chaos, retro-futuristic, organic/natural, luxury/refined, playful/toy-like, editorial/magazine, brutalist/raw, art deco/geometric, soft/pastel, industrial/utilitarian, etc. There are so many flavors to choose from. Use these for inspiration but design one that is true to the aesthetic direction.
- **Constraints**: Technical requirements (framework, performance, accessibility).
- **Differentiation**: What makes this UNFORGETTABLE? What's the one thing someone will remember?
**CRITICAL**: Choose a clear conceptual direction and execute it with precision. Bold maximalism and refined minimalism both work - the key is intentionality, not intensity.
Then implement working code (HTML/CSS/JS, React, Vue, etc.) that is:
- Production-grade and functional
- Visually striking and memorable
- Cohesive with a clear aesthetic point-of-view
- Meticulously refined in every detail
## Frontend Aesthetics Guidelines
Focus on:
- **Typography**: Choose fonts that are beautiful, unique, and interesting. Avoid generic fonts like Arial and Inter; opt instead for distinctive choices that elevate the frontend's aesthetics; unexpected, characterful font choices. Pair a distinctive display font with a refined body font.
- **Color & Theme**: Commit to a cohesive aesthetic. Use CSS variables for consistency. Dominant colors with sharp accents outperform timid, evenly-distributed palettes.
- **Motion**: Use animations for effects and micro-interactions. Prioritize CSS-only solutions for HTML. Use Motion library for React when available. Focus on high-impact moments: one well-orchestrated page load with staggered reveals (animation-delay) creates more delight than scattered micro-interactions. Use scroll-triggering and hover states that surprise.
- **Spatial Composition**: Unexpected layouts. Asymmetry. Overlap. Diagonal flow. Grid-breaking elements. Generous negative space OR controlled density.
- **Backgrounds & Visual Details**: Create atmosphere and depth rather than defaulting to solid colors. Add contextual effects and textures that match the overall aesthetic. Apply creative forms like gradient meshes, noise textures, geometric patterns, layered transparencies, dramatic shadows, decorative borders, custom cursors, and grain overlays.
NEVER use generic AI-generated aesthetics like overused font families (Inter, Roboto, Arial, system fonts), cliched color schemes (particularly purple gradients on white backgrounds), predictable layouts and component patterns, and cookie-cutter design that lacks context-specific character.
Interpret creatively and make unexpected choices that feel genuinely designed for the context. No design should be the same. Vary between light and dark themes, different fonts, different aesthetics. NEVER converge on common choices (Space Grotesk, for example) across generations.
**IMPORTANT**: Match implementation complexity to the aesthetic vision. Maximalist designs need elaborate code with extensive animations and effects. Minimalist or refined designs need restraint, precision, and careful attention to spacing, typography, and subtle details. Elegance comes from executing the vision well.
Remember: Claude is capable of extraordinary creative work. Don't hold back, show what can truly be created when thinking outside the box and committing fully to a distinctive vision.
+327
View File
@@ -0,0 +1,327 @@
---
name: material-thinking
description: Comprehensive Material Design 3 (M3) and M3 Expressive guidance for building modern, accessible, and engaging user interfaces. Use when designing or implementing Material Design interfaces, reviewing component designs for M3 compliance, generating design tokens (color schemes, typography, shapes), applying M3 Expressive motion and interactions, or migrating existing UIs to Material 3. Covers all 38 M3 components, foundations (accessibility, layout, interaction), styles (color, typography, elevation, shape, icons, motion), and M3 Expressive tactics for more engaging experiences.
---
# Material Thinking
Apply Material Design 3 and M3 Expressive principles to create accessible, consistent, and engaging user interfaces.
## Overview
This skill provides comprehensive guidance for implementing Material Design 3 (M3) and M3 Expressive across all platforms. Material Design 3 is Google's open-source design system that provides UX guidance, reusable components, and design tools for creating beautiful, accessible interfaces.
**Key capabilities:**
- Design new products with M3 principles
- Review existing designs for M3 compliance
- Generate design tokens (colors, typography, shapes)
- Apply M3 Expressive for engaging, emotionally resonant UIs
- Select appropriate components for specific use cases
- Ensure accessibility and responsive design
## Core Resources
This skill includes four comprehensive reference documents. Read these as needed during your work:
### 1. Foundations (`references/foundations.md`)
Read when working with:
- Accessibility requirements
- Layout and responsive design (window size classes, canonical layouts)
- Interaction patterns (states, gestures, selection)
- Content design and UX writing
- Design tokens and adaptive design
### 2. Styles (`references/styles.md`)
Read when working with:
- Color systems (dynamic color, color roles, tonal palettes)
- Typography (type scale, fonts)
- Elevation and depth
- Shapes and corner radius
- Icons (Material Symbols)
- Motion and transitions
### 3. Components (`references/components.md`)
Read when selecting or implementing:
- Action components (buttons, FAB, segmented buttons)
- Selection and input (checkbox, radio, switch, text fields, chips)
- Navigation (nav bar, drawer, rail, app bars, tabs)
- Containment and layout (cards, lists, carousel, sheets)
- Communication (dialogs, snackbar, badges, progress, tooltips, menus)
### 4. M3 Expressive (`references/m3-expressive.md`)
Read when creating more engaging experiences:
- Expressive motion tactics
- Shape morphing
- Dynamic animations
- Brand expression
- Balancing expressiveness with usability
## Workflows
### Workflow 1: Designing a New Interface
When designing a new product or feature with Material 3:
1. **Define layout structure**
- Read `references/foundations.md` → Layout section
- Determine window size classes (compact/medium/expanded)
- Choose canonical layout if applicable (list-detail, feed, supporting pane)
2. **Select components**
- Read `references/components.md`
- Use Component Selection Guide to choose appropriate components
- Review specific component guidelines for usage and specs
3. **Establish visual style**
- Read `references/styles.md`
- Define color scheme (dynamic or static)
- Set typography scale
- Choose shape scale (corner radius values)
- Plan motion and transitions
4. **Apply M3 Expressive (optional)**
- Read `references/m3-expressive.md`
- Identify key moments for expressive design
- Apply emphasized easing and extended durations
- Consider shape morphing for transitions
- Follow 80/20 rule (80% standard, 20% expressive)
5. **Validate accessibility**
- Read `references/foundations.md` → Accessibility section
- Check color contrast (WCAG compliance)
- Verify touch targets (minimum 48×48dp)
- Ensure keyboard navigation and screen reader support
### Workflow 2: Reviewing Existing Designs
When reviewing designs for Material 3 compliance:
1. **Component compliance**
- Read `references/components.md` for relevant components
- Check if components follow M3 specifications
- Verify proper component usage (e.g., not using filled buttons excessively)
- Validate component states (enabled, hover, focus, pressed, disabled)
2. **Visual consistency**
- Read `references/styles.md`
- Verify color roles are used correctly
- Check typography matches type scale
- Validate elevation levels
- Review shape consistency (corner radius)
3. **Accessibility audit**
- Read `references/foundations.md` → Accessibility section
- Test color contrast ratios
- Check touch target sizes
- Verify text resizing support
- Review focus indicators
4. **Interaction patterns**
- Read `references/foundations.md` → Interaction section
- Verify state layers are present
- Check gesture support for mobile
- Validate selection patterns
### Workflow 3: Generating Design Tokens
When creating design tokens for a new theme:
1. **Color tokens**
- Read `references/styles.md` → Color section
- Choose color scheme type (dynamic or static)
- Define source color(s)
- Generate tonal palette (13 tones per key color)
- Map color roles (primary, secondary, tertiary, surface, error)
- Create light and dark theme variants
2. **Typography tokens**
- Read `references/styles.md` → Typography section
- Select font family
- Define type scale (display, headline, title, body, label × small/medium/large)
- Set letter spacing and line height
3. **Shape tokens**
- Read `references/styles.md` → Shape section
- Define corner radius scale (none, extra-small, small, medium, large, extra-large)
- Map shapes to component categories
4. **Motion tokens**
- Read `references/styles.md` → Motion section
- Define duration values (short, medium, long)
- Set easing curves (emphasized, standard)
- For M3 Expressive, read `references/m3-expressive.md` → Expressive Motion
### Workflow 4: Implementing M3 Expressive
When adding expressive elements to enhance engagement:
1. **Identify key moments**
- Onboarding flows
- Primary user actions (FAB, main CTAs)
- Screen transitions
- Success/completion states
2. **Apply expressive tactics**
- Read `references/m3-expressive.md` → Design Tactics
- Use emphasized easing for important transitions
- Extend animation durations (400-700ms)
- Add exaggerated scale changes
- Implement layered/staggered animations
- Consider shape morphing
3. **Balance with usability**
- Follow 80/20 rule (most interactions remain standard)
- Respect `prefers-reduced-motion`
- Avoid excessive motion in productivity contexts
- Test on lower-end devices
## Quick Reference
### When to Read Each Reference
| Your Question | Read This |
| ---------------------------------------------- | ---------------------------------------------------------- |
| "What components should I use for navigation?" | `references/components.md` → Navigation Components |
| "How do I create a color scheme?" | `references/styles.md` → Color |
| "What are the responsive breakpoints?" | `references/foundations.md` → Layout → Window Size Classes |
| "How do I make my design more engaging?" | `references/m3-expressive.md` |
| "What's the correct button hierarchy?" | `references/components.md` → Action Components → Buttons |
| "How do I ensure accessibility?" | `references/foundations.md` → Accessibility |
| "What motion timing should I use?" | `references/styles.md` → Motion |
| "How do I implement shape morphing?" | `references/m3-expressive.md` → Shape and Form |
### Component Quick Selector
**Actions:**
- Primary screen action → FAB or Filled Button
- Secondary action → Tonal/Outlined Button
- Tertiary action → Text Button
- Compact action → Icon Button
- Toggle options (2-5) → Segmented Button
**Input:**
- Single choice → Radio Button
- Multiple choices → Checkbox
- On/Off toggle → Switch
- Text input → Text Field
- Date/time → Date/Time Picker
- Range value → Slider
- Tags → Input Chips
**Navigation:**
- Compact screens (<600dp) → Navigation Bar
- Medium screens (600-840dp) → Navigation Rail
- Large screens (>840dp) → Navigation Drawer
- Secondary nav → Tabs or Top App Bar
**Communication:**
- Important decision → Dialog
- Quick feedback → Snackbar
- Notification count → Badge
- Loading status → Progress Indicator
- Contextual help → Tooltip
- Action list → Menu
### Design Token Defaults
**Color (Light Theme):**
- Primary: tone 40
- On-primary: tone 100 (white)
- Primary container: tone 90
- Surface: tone 98
**Typography:**
- Display Large: 57sp
- Headline Large: 32sp
- Title Large: 22sp
- Body Large: 16sp
- Label Large: 14sp
**Shape:**
- Extra Small: 4dp (chips, checkboxes)
- Small: 8dp (small buttons)
- Medium: 12dp (cards, standard buttons)
- Large: 16dp (FAB)
- Extra Large: 28dp (dialogs, sheets)
**Elevation:**
- Level 0: 0dp (standard surface)
- Level 1: 1dp (cards)
- Level 2: 3dp (search bars)
- Level 3: 6dp (FAB)
- Level 5: 12dp (modals, dialogs)
## Best Practices
### General Principles
1. **Consistency**: Use design tokens consistently across the product
2. **Hierarchy**: Establish clear visual hierarchy through size, color, and spacing
3. **Accessibility**: Always meet WCAG 2.1 Level AA standards (minimum)
4. **Responsiveness**: Design for all window size classes
5. **Platform conventions**: Respect platform-specific patterns when appropriate
### Common Mistakes to Avoid
- **Too many filled buttons**: Use only one filled button per screen for primary action
- **Ignoring window size classes**: Design must adapt to different screen sizes
- **Poor color contrast**: Always validate contrast ratios
- **Inconsistent spacing**: Use 4dp grid system throughout
- **Overusing M3 Expressive**: Keep 80% standard, 20% expressive
- **Small touch targets**: Minimum 48×48dp for all interactive elements
- **Unclear component states**: All components must show hover, focus, pressed states
### Platform-Specific Notes
**Flutter:**
- Use Material 3 theme in `ThemeData(useMaterial3: true)`
- Access design tokens via `Theme.of(context)`
- Official documentation: https://m3.material.io/develop/flutter
**Android (Jetpack Compose):**
- Use Material3 package
- MaterialTheme provides M3 components and tokens
- Official documentation: https://m3.material.io/develop/android/jetpack-compose
**Web:**
- Use Material Web Components library
- CSS custom properties for design tokens
- Official documentation: https://m3.material.io/develop/web
**Platform-agnostic:**
- Export design tokens from Material Theme Builder
- Apply M3 principles manually to any framework
## Tools and Resources
### Material Theme Builder
Web-based tool for creating M3 color schemes and design tokens:
- Generate color schemes from source colors
- Create light and dark themes
- Export tokens for various platforms
- URL: https://material-foundation.github.io/material-theme-builder/
### Material Symbols
Variable icon font with 2,500+ icons:
- Styles: Outlined, Filled, Rounded, Sharp
- Variable features: weight, grade, optical size, fill
- URL: https://fonts.google.com/icons
### Official Documentation
- Material Design 3: https://m3.material.io/
- Get Started: https://m3.material.io/get-started
- Blog (updates and announcements): https://m3.material.io/blog
## Summary
This skill enables comprehensive Material Design 3 implementation:
1. **Read references as needed**: Don't try to memorize everything—reference files exist to be consulted during work
2. **Follow workflows**: Use structured workflows for common tasks (designing, reviewing, generating tokens)
3. **Start with foundations**: Layout, accessibility, and interaction patterns form the base
4. **Build with components**: Use the 38 documented M3 components appropriately
5. **Apply styles consistently**: Color, typography, shape, elevation, icons, motion
6. **Enhance with M3 Expressive**: Add engaging, emotionally resonant elements where appropriate
7. **Validate accessibility**: Always check contrast, touch targets, and keyboard navigation
Material Design 3 is a complete design system—this skill helps you apply it effectively across all contexts.
@@ -0,0 +1,515 @@
# Material 3 Components
Material 3は38のドキュメント化されたコンポーネントを提供します。各コンポーネントには、概要、ガイドライン、仕様、アクセシビリティのサブページがあります。
## Table of Contents
1. [Action Components](#action-components)
2. [Selection and Input Components](#selection-and-input-components)
3. [Navigation Components](#navigation-components)
4. [Containment and Layout Components](#containment-and-layout-components)
5. [Communication Components](#communication-components)
---
## Action Components
ユーザーがアクションを実行するためのコンポーネント。
### Buttons
#### Common Buttons
主要なアクションのための標準的なボタン。
**Variants:**
- **Filled**: 最も高い強調度、プライマリアクション
- **Filled Tonal**: 中程度の強調度、セカンダリアクション
- **Outlined**: 線のみ、中程度の強調度
- **Elevated**: 影付き、強調が必要だがFilledほどではない
- **Text**: 最も低い強調度、補助的なアクション
**Usage Guidelines:**
- 1つの画面にFilledボタンは1つまで推奨
- ボタンの階層を明確に(Filled > Tonal > Outlined > Text
- 最小タッチターゲット: 48×48dp
- ラベルは動詞で開始(例: "保存", "送信", "削除"
URL: https://m3.material.io/components/buttons/overview
#### Icon Buttons
コンパクトな補助的アクションボタン。
**Variants:**
- Standard
- Filled
- Filled Tonal
- Outlined
**Usage:**
- 繰り返し使用されるアクション(お気に入り、共有、削除)
- 限られたスペース
- アイコンのみで意味が明確な場合
URL: https://m3.material.io/components/icon-buttons/overview
#### Floating Action Button (FAB)
画面の主要アクションのための浮遊ボタン。
**Types:**
- **FAB**: 標準的なFAB
- **Small FAB**: 小さいFAB
- **Large FAB**: 大きいFAB
- **Extended FAB**: テキストラベル付きFAB
**Guidelines:**
- 1画面に1つのFAB推奨
- 最も重要なアクションのみ
- 配置: 通常は右下
- スクロール時の動作を考慮(隠す/縮小)
URL: https://m3.material.io/components/floating-action-button/overview
#### Segmented Buttons
関連するオプションの単一選択または複数選択グループ。
**Usage:**
- ビューの切り替え(リスト/グリッド)
- フィルタリング(カテゴリ選択)
- 設定オプション
**Guidelines:**
- 2-5個のオプション推奨
- 各オプションは簡潔に(1-2語)
- アイコン+テキストまたはテキストのみ
URL: https://m3.material.io/components/segmented-buttons/overview
---
## Selection and Input Components
ユーザーが選択や入力を行うためのコンポーネント。
### Checkbox
リストから複数のアイテムを選択。
**States:**
- Unchecked
- Checked
- Indeterminate(部分選択)
**Usage:**
- 複数選択
- オン/オフ設定(ただしSwitchの方が適切な場合も)
- リスト項目の選択
URL: https://m3.material.io/components/checkbox/guidelines
### Radio Button
セットから1つのオプションを選択。
**Usage:**
- 相互排他的なオプション(1つのみ選択可能)
- すべてのオプションを表示する必要がある場合
- 2-7個のオプション推奨
**Guidelines:**
- デフォルト選択肢を提供
- オプションは垂直に配置推奨
- ラベルはクリック可能に
URL: https://m3.material.io/components/radio-button/overview
### Switch
バイナリのオン/オフ切り替え。
**Usage:**
- 即座に効果が反映される設定
- 単一アイテムの有効/無効化
- リスト内の個別項目の切り替え
**vs Checkbox:**
- Switch: 即座に効果、状態の切り替え
- Checkbox: 保存が必要、複数選択
URL: https://m3.material.io/components/switch/guidelines
### Text Fields
テキスト入力用のフォームフィールド。
**Types:**
- **Filled**: デフォルト、背景塗りつぶし
- **Outlined**: 線のみ、フォーム内で推奨
**Elements:**
- Label: 入力内容の説明
- Input text: ユーザー入力
- Helper text: 補助的な説明
- Error text: エラーメッセージ
- Leading/Trailing icons: アイコン
**Guidelines:**
- ラベルは簡潔に
- プレースホルダーは補助的な例として使用
- エラーは具体的に("無効な入力" ではなく "有効なメールアドレスを入力してください")
URL: https://m3.material.io/components/text-fields/overview
### Chips
コンパクトな情報要素。
**Types:**
- **Assist**: アクションやヘルプのサジェスト
- **Filter**: コンテンツのフィルタリング
- **Input**: ユーザー入力(タグ、連絡先)
- **Suggestion**: 動的な提案
**Usage:**
- タグや属性の表示
- フィルタリングオプション
- 選択されたアイテムの表示
URL: https://m3.material.io/components/chips/guidelines
### Sliders
範囲内の値を選択。
**Types:**
- Continuous: 連続的な値
- Discrete: 離散的な値(ステップ付き)
**Usage:**
- 音量、明るさ調整
- 価格範囲選択
- 数値設定
URL: https://m3.material.io/components/sliders/specs
### Date Pickers / Time Pickers
日付と時刻の選択。
**Date Picker Modes:**
- Modal: ダイアログ形式
- Docked: インライン表示
**Time Picker Types:**
- Dial: ダイヤル形式
- Input: テキスト入力形式
URL: https://m3.material.io/components/date-pickers
---
## Navigation Components
アプリ内のナビゲーションを提供するコンポーネント。
### Navigation Bar
モバイル向けボトムナビゲーション。
**Guidelines:**
- 3-5個の主要な目的地
- アイコン+ラベル(アイコンのみは避ける)
- 常に表示(スクロールしても固定)
- Compact window size class向け
URL: https://m3.material.io/components/navigation-bar/overview
### Navigation Drawer
サイドナビゲーション。
**Types:**
- **Standard**: 画面端から開閉
- **Modal**: オーバーレイ形式
**Usage:**
- 5個以上の目的地
- Medium/Expanded window size class
- アプリの主要セクション
URL: https://m3.material.io/components/navigation-drawer/overview
### Navigation Rail
垂直方向のナビゲーション(中型画面)。
**Usage:**
- Medium window size class(タブレット縦向き)
- 3-7個の目的地
- 画面左端に固定
URL: https://m3.material.io/components/navigation-rail/overview
### Top App Bar
画面上部のタイトルとアクション。
**Types:**
- **Small**: 標準的なアプリバー
- **Medium**: 中サイズ(スクロールで縮小)
- **Large**: 大サイズ(スクロールで縮小)
**Elements:**
- Navigation icon: 戻る、メニュー
- Title: 画面タイトル
- Action icons: 主要なアクション(最大3つ推奨)
URL: https://m3.material.io/components/app-bars/overview
### Tabs
コンテンツを複数のビューに整理。
**Types:**
- Primary tabs: メインコンテンツの切り替え
- Secondary tabs: サブセクションの切り替え
**Guidelines:**
- 2-6個のタブ推奨
- ラベルは簡潔に(1-2語)
- スワイプジェスチャーでの切り替えをサポート
URL: https://m3.material.io/components/tabs/guidelines
---
## Containment and Layout Components
コンテンツを整理・表示するためのコンポーネント。
### Cards
関連情報をまとめたコンテナ。
**Types:**
- **Elevated**: 影付き
- **Filled**: 背景塗りつぶし
- **Outlined**: 線のみ
**Usage:**
- 異なるコンテンツのコレクション
- アクション可能なコンテンツ
- エントリーポイント
**Guidelines:**
- 過度に使用しない(リストで十分な場合も)
- 明確なアクションを提供
- 情報の階層を維持
URL: https://m3.material.io/components/cards/guidelines
### Lists
垂直方向のテキストと画像のインデックス。
**Types:**
- Single-line
- Two-line
- Three-line
**Elements:**
- Leading element: アイコン、画像、チェックボックス
- Primary text: メインテキスト
- Secondary text: サブテキスト
- Trailing element: メタ情報、アクション
**Usage:**
- 同質なコンテンツのコレクション
- スキャン可能な情報
- 詳細へのエントリーポイント
URL: https://m3.material.io/components/lists/overview
### Carousel
スクロール可能なビジュアルアイテムのコレクション。
**Types:**
- Hero: 大きい、フォーカスされたアイテム
- Multi-browse: 複数アイテム表示
- Uncontained: フルブリード
**Usage:**
- 画像ギャラリー
- プロダクトショーケース
- オンボーディング
URL: https://m3.material.io/components/carousel/overview
### Bottom Sheets / Side Sheets
追加コンテンツを表示するサーフェス。
**Types:**
- **Standard**: 永続的、画面の一部
- **Modal**: 一時的、フォーカスが必要
**Bottom Sheet Usage:**
- コンテキストアクション
- 追加オプション
- Mobile向け
**Side Sheet Usage:**
- 詳細情報、フィルタ
- Tablet/Desktop向け
URL: https://m3.material.io/components/bottom-sheets/overview
---
## Communication Components
ユーザーにフィードバックや情報を伝えるコンポーネント。
### Dialogs
ユーザーアクションが必要な重要なプロンプト。
**Types:**
- **Basic**: タイトル、本文、アクション
- **Full-screen**: フルスクリーンダイアログ(モバイル)
**Usage:**
- 重要な決定(削除確認など)
- 必須の情報入力
- エラーや警告
**Guidelines:**
- タイトルは質問形式推奨
- アクションは明確に("削除"、"キャンセル"
- 破壊的なアクションは右側に配置しない
URL: https://m3.material.io/components/dialogs/guidelines
### Snackbar
プロセスの簡潔な更新を画面下部に表示。
**Usage:**
- 操作完了の確認("メッセージを送信しました")
- 軽微なエラー通知
- オプショナルなアクション提供
**Guidelines:**
- 表示時間: 4-10秒
- 1行のメッセージ推奨
- 最大1つのアクション
- 重要な情報には使用しない(Dialogを使用)
URL: https://m3.material.io/components/snackbar/overview
### Badges
ナビゲーション項目上の通知とカウント。
**Types:**
- Numeric: 数値表示(1-999
- Dot: ドット表示(新着あり)
**Usage:**
- 未読通知の数
- 新着コンテンツのインジケーター
URL: https://m3.material.io/components/badges/overview
### Progress Indicators
進行中のプロセスのステータス表示。
**Types:**
- **Circular**: 円形、不定期または確定的
- **Linear**: 線形、確定的な進捗
**Usage:**
- Circular: ローディング、処理中
- Linear: ファイルアップロード、ダウンロード
**Guidelines:**
- 2秒以上かかる処理で表示
- 可能な限り確定的な進捗を使用
- 進捗率がわからない場合は不定期
URL: https://m3.material.io/components/progress-indicators/overview
### Tooltips
コンテキストラベルとメッセージ。
**Types:**
- Plain: テキストのみ
- Rich: テキスト+アイコン/画像
**Usage:**
- アイコンボタンの説明
- 切り詰められたテキストの完全版
- 補助的な情報
**Guidelines:**
- 簡潔に(1行推奨)
- 重要な情報には使用しない
- タッチデバイスではlong press
URL: https://m3.material.io/components/tooltips/guidelines
### Menus
一時的なサーフェース上の選択肢リスト。
**Types:**
- Standard menu
- Dropdown menu
- Exposed dropdown menu(選択状態を表示)
**Usage:**
- コンテキストメニュー
- 選択オプション
- アクションのリスト
**Guidelines:**
- 2-7個のアイテム推奨
- アイコンはオプション
- 破壊的なアクションは分離
URL: https://m3.material.io/components/menus/overview
### Search
検索バーとサジェスト。
**Elements:**
- Search bar: 検索入力フィールド
- Search view: 全画面検索インターフェース
**Usage:**
- アプリ内検索
- フィルタリング
- サジェスト表示
URL: https://m3.material.io/components/search/overview
---
## Component Selection Guide
### Action Selection
| Need | Component |
|------|-----------|
| Primary screen action | FAB or Filled Button |
| Secondary action | Tonal/Outlined Button |
| Tertiary action | Text Button |
| Compact action | Icon Button |
| Toggle between 2-5 options | Segmented Button |
### Input Selection
| Need | Component |
|------|-----------|
| Single choice from list | Radio Button |
| Multiple choices | Checkbox |
| On/Off toggle | Switch |
| Text input | Text Field |
| Date selection | Date Picker |
| Value from range | Slider |
| Tags or attributes | Input Chips |
### Navigation Selection
| Window Size | Primary Nav | Secondary Nav |
|-------------|-------------|---------------|
| Compact (<600dp) | Navigation Bar | Tabs |
| Medium (600-840dp) | Navigation Rail | Tabs |
| Expanded (>840dp) | Navigation Drawer | Tabs, Top App Bar |
---
## References
- Material Design 3 Components: https://m3.material.io/components/
- All Components List: https://m3.material.io/components/all-buttons
@@ -0,0 +1,207 @@
# Material 3 Foundations
Material 3 Foundationsは、すべてのMaterialインターフェースの基盤となる設計原則とパターンを定義します。
## Table of Contents
1. [Accessibility](#accessibility)
2. [Layout](#layout)
3. [Interaction](#interaction)
4. [Content Design](#content-design)
5. [Design Tokens](#design-tokens)
6. [Adaptive Design](#adaptive-design)
---
## Accessibility
### Core Principles
- 多様な能力を持つユーザーのための設計
- スクリーンリーダーなどの支援技術との統合
- WCAG準拠のコントラスト比
### Key Areas
#### Structure and Elements
- 直感的なレイアウト階層
- アクセシブルなUI要素の設計
- フォーカス管理とナビゲーション
URL: https://m3.material.io/foundations/designing/structure
#### Color Contrast
- WCAG準拠のカラーコントラスト
- テキストとUIコントロールの視認性
- 4.5:1(通常テキスト)、3:1(大きいテキスト、UIコンポーネント)
URL: https://m3.material.io/foundations/designing/color-contrast
#### Text Accessibility
- テキストリサイズのサポート(200%まで)
- アクセシブルなテキスト切り詰め
- 明確で適応可能な文章
URL: https://m3.material.io/foundations/writing/text-resizing
---
## Layout
### Understanding Layout
#### Core Components
- **Regions**: 画面の主要エリア(ヘッダー、本文、ナビゲーション)
- **Columns**: グリッドシステムの基本単位
- **Gutters**: カラム間のスペース
- **Spacing**: 4dpベースの一貫したスペーシングシステム
URL: https://m3.material.io/foundations/layout/understanding-layout/overview
### Window Size Classes
画面サイズに応じたレスポンシブデザイン:
| Size Class | Width | Typical Device | Key Patterns |
|-----------|-------|---------------|--------------|
| Compact | <600dp | Phone | Single pane, bottom nav |
| Medium | 600-840dp | Tablet (portrait) | Dual pane optional, nav rail |
| Expanded | >840dp | Tablet (landscape), Desktop | Dual/multi pane, nav drawer |
| Large/XL | >1240dp | Large screens, TV | Multi-pane, extensive nav |
URL: https://m3.material.io/foundations/layout/applying-layout/window-size-classes
### Canonical Layouts
よく使われるレイアウトパターン:
1. **List-detail**: マスター・詳細ナビゲーション
2. **Feed**: コンテンツフィード
3. **Supporting pane**: 補助コンテンツパネル
URL: https://m3.material.io/foundations/layout/canonical-layouts/overview
---
## Interaction
### States
#### Visual States
- **Enabled**: デフォルト状態
- **Hover**: ポインタがホバーしている状態(デスクトップ)
- **Focused**: キーボードフォーカス
- **Pressed**: アクティブに押されている状態
- **Dragged**: ドラッグ中
- **Disabled**: 無効化状態
#### State Layers
半透明なオーバーレイで状態を視覚的に示す:
- Hover: 8% opacity
- Focus: 12% opacity
- Press: 12% opacity
URL: https://m3.material.io/foundations/interaction/states/state-layers
### Gestures
モバイルインターフェース向けタッチジェスチャー:
- Tap: 基本的な選択
- Long press: コンテキストメニュー
- Drag: 移動、並べ替え
- Swipe: ナビゲーション、削除
- Pinch: ズーム
URL: https://m3.material.io/foundations/interaction/gestures
### Selection
選択インタラクションパターン:
- **Single selection**: ラジオボタン、リスト項目
- **Multi selection**: チェックボックス、選択可能なリスト
URL: https://m3.material.io/foundations/interaction/selection
---
## Content Design
### UX Writing Principles
1. **Clear**: 明確で理解しやすい
2. **Concise**: 簡潔で要点を押さえた
3. **Useful**: ユーザーのニーズに応える
4. **Consistent**: 用語とトーンの一貫性
### Notifications
効果的な通知コンテンツ:
- アクション可能な情報
- 明確な次のステップ
- ユーザーコンテキストの理解
URL: https://m3.material.io/foundations/content-design/notifications
### Alt Text
アクセシブルな画像説明:
- 装飾的画像: 空のalt属性
- 機能的画像: アクションを説明
- 情報的画像: 内容を簡潔に説明
URL: https://m3.material.io/foundations/content-design/alt-text
### Global Writing
国際的なオーディエンス向けの文章:
- ローカライゼーションを考慮した単語選択
- 文化的に中立な表現
- 翻訳しやすい文法構造
URL: https://m3.material.io/foundations/content-design/global-writing/overview
---
## Design Tokens
### What are Design Tokens?
デザイントークンは、デザイン、ツール、コード全体で使用される設計上の決定の最小単位:
- **Color tokens**: primary, secondary, surface, error など
- **Typography tokens**: displayLarge, bodyMedium など
- **Shape tokens**: cornerRadius, roundedCorner など
- **Motion tokens**: duration, easing curves
### Benefits
- デザインとコード間の一貫性
- テーマのカスタマイズが容易
- プラットフォーム間での統一
URL: https://m3.material.io/foundations/design-tokens/overview
---
## Adaptive Design
### Principles
- **Responsive**: ウィンドウサイズに応じた調整
- **Adaptive**: デバイス特性に応じた最適化
- **Contextual**: 使用コンテキストを考慮
### Key Strategies
1. Window size classesに基づくレイアウト調整
2. 入力方式(タッチ、マウス、キーボード)への対応
3. デバイス機能(カメラ、位置情報等)の活用
4. オフラインとオンラインシナリオの対応
URL: https://m3.material.io/foundations/adaptive-design
---
## References
- Material Design 3 Foundations: https://m3.material.io/foundations/
- Glossary: https://m3.material.io/foundations/glossary
@@ -0,0 +1,470 @@
# Material 3 Expressive
M3 Expressiveは、Googleが2024-2025年に導入したMaterial 3の進化版で、より魅力的で感情的に共鳴するインターフェースを実現します。
## Table of Contents
1. [Overview](#overview)
2. [Usability Principles](#usability-principles)
3. [Design Tactics](#design-tactics)
4. [Expressive Motion](#expressive-motion)
5. [Shape and Form](#shape-and-form)
6. [Implementation Guidelines](#implementation-guidelines)
---
## Overview
### What is M3 Expressive?
M3 Expressiveは、標準のMaterial 3を拡張し、以下を実現します:
- **Engaging**: ユーザーの注意を引き、関心を維持
- **Emotionally resonant**: 感情的なつながりを生む
- **User-friendly**: 使いやすさを犠牲にしない
- **Brand expression**: ブランドの個性を表現
### Key Differences from Standard M3
| Aspect | Standard M3 | M3 Expressive |
|--------|-------------|---------------|
| Motion | 控えめ、機能的 | 大胆、表現豊か |
| Shapes | 一貫した角丸 | 動的な形状変形 |
| Emphasis | 明確、シンプル | ドラマチック、インパクト |
| Timing | 速い(200-300ms | やや長め(400-700ms |
URL: https://m3.material.io/blog/building-with-m3-expressive
---
## Usability Principles
### Creating Engaging Products
M3 Expressiveは、以下のusability原則に基づきます:
#### 1. Guide Users
ユーザーを適切に誘導する:
- **Motion paths**: アニメーションでフローを示す
- **Visual hierarchy**: 動きで注意を引く
- **Staged reveal**: 段階的に情報を開示
#### 2. Emphasize Actions
重要なアクションを強調:
- **Scale changes**: サイズ変化で重要性を示す
- **Color dynamics**: 色の変化で状態を表現
- **Focused attention**: 1つの要素に注意を集中
#### 3. Provide Feedback
ユーザーのアクションに対する明確なフィードバック:
- **Immediate response**: 即座の視覚的反応
- **State transitions**: 状態変化を明確に表現
- **Completion signals**: アクション完了を示す
URL: https://m3.material.io/foundations/usability/overview
---
## Design Tactics
M3 Expressiveを実装するための具体的なデザイン戦術。
URL: https://m3.material.io/foundations/usability/applying-m-3-expressive
### 1. Emphasized Easing
**Standard easing**よりも劇的な**Emphasized easing**を使用:
```
Emphasized Decelerate: cubic-bezier(0.05, 0.7, 0.1, 1.0)
Emphasized Accelerate: cubic-bezier(0.3, 0.0, 0.8, 0.15)
```
**When to use:**
- 重要なトランジション
- ユーザーの注意を引く必要がある場合
- ブランド表現を強化したい場合
**Example:**
```css
.expressive-enter {
animation: enter 500ms cubic-bezier(0.05, 0.7, 0.1, 1.0);
}
```
### 2. Extended Duration
標準より長いアニメーション時間:
| Element | Standard | Expressive |
|---------|----------|------------|
| Small changes | 100ms | 150-200ms |
| Medium changes | 250ms | 400-500ms |
| Large transitions | 300ms | 500-700ms |
**Caution:** 1000msを超えないこと
### 3. Exaggerated Scale
スケール変化を誇張:
**Standard:**
- Scale: 1.0 → 1.05+5%
**Expressive:**
- Scale: 1.0 → 1.15+15%
- Scale: 1.0 → 0.9 → 1.1bounce effect
**Example use case:**
- FABのタップアニメーション
- カードの選択状態
- アイコンのアクティブ状態
### 4. Dynamic Color Transitions
色の動的な変化:
**Techniques:**
- Gradient animations: グラデーションの動的変化
- Color pulse: 色のパルス効果
- Hue rotation: 色相の変化
**Example:**
```css
.expressive-button:active {
background: linear-gradient(45deg, primary, tertiary);
transition: background 400ms cubic-bezier(0.05, 0.7, 0.1, 1.0);
}
```
### 5. Layered Motion
複数の要素が異なるタイミングで動く:
**Stagger animations:**
- 遅延: 50-100ms per item
- リストアイテムの順次表示
- カードグリッドの表示
**Example timing:**
```
Item 1: 0ms
Item 2: 80ms
Item 3: 160ms
Item 4: 240ms
```
### 6. Shape Morphing
形状の動的な変形(後述)
---
## Expressive Motion
M3 Expressiveの中核となるモーションシステム。
URL: https://m3.material.io/blog/m3-expressive-motion-theming
### Motion Theming System
カスタマイズ可能な新しいモーションテーマシステム:
#### Motion Tokens
**Duration tokens:**
```
motion.duration.short: 150ms
motion.duration.medium: 400ms
motion.duration.long: 600ms
motion.duration.extra-long: 1000ms
```
**Easing tokens:**
```
motion.easing.emphasized: cubic-bezier(0.05, 0.7, 0.1, 1.0)
motion.easing.emphasizedDecelerate: cubic-bezier(0.05, 0.7, 0.1, 1.0)
motion.easing.emphasizedAccelerate: cubic-bezier(0.3, 0.0, 0.8, 0.15)
motion.easing.standard: cubic-bezier(0.2, 0.0, 0, 1.0)
```
### Expressive Transition Patterns
#### 1. Container Transform (Enhanced)
**Standard container transform:**
- Duration: 300ms
- Easing: standard
**Expressive container transform:**
- Duration: 500ms
- Easing: emphasized
- 追加効果: 軽いスケール変化、色の変化
#### 2. Shared Axis (Enhanced)
**Expressive enhancements:**
- より大きいスライド距離(+20%
- フェード+スケール効果の組み合わせ
- ステージングされた要素の動き
#### 3. Morph Transition
新しいトランジションタイプ:
- 形状の滑らかな変形
- 複数プロパティの同時変化(サイズ、色、形状)
- 有機的な動き
**Example:**
```
Circle → Rounded Rectangle → Full Screen
(300ms) → (200ms)
```
### Micro-interactions
小さいが印象的なインタラクション:
#### Button Press
```
1. Scale down: 0.95 (50ms)
2. Scale up: 1.0 (150ms, emphasized easing)
3. Ripple effect: expanded, slower
```
#### Icon State Change
```
1. Scale out: 0.8 + rotate 15deg (100ms)
2. Icon swap
3. Scale in: 1.0 + rotate 0deg (200ms, emphasized)
```
#### Loading States
```
- Pulse animation: 1.0 → 1.1 → 1.0 (800ms, loop)
- Color shift: primary → tertiary → primary
```
---
## Shape and Form
### Shape Morph
動的な形状変形でブランド表現を強化。
URL: https://m3.material.io/styles/shape/shape-morph
#### Basic Shape Morph
形状の滑らかな変化:
**Example scenarios:**
1. **FAB → Dialog**
- Circle (56dp) → Rounded rectangle (280×400dp)
- Duration: 500ms
- Easing: emphasized decelerate
2. **Chip → Card**
- Small rounded (32dp) → Medium rounded (card size)
- Duration: 400ms
3. **Button → Full Width**
- Fixed width → Full screen width
- Corner radius維持
#### Advanced Techniques
**Path morphing:**
- SVGパスの変形
- ベジェ曲線の補間
- 複雑な形状間の遷移
**Example SVG morph:**
```svg
<path d="M10,10 L90,10 L90,90 L10,90 Z">
<animate attributeName="d"
to="M50,10 L90,50 L50,90 L10,50 Z"
dur="500ms"
fill="freeze"/>
</path>
```
### Organic Shapes
より自然で有機的な形状:
**Characteristics:**
- 非対称な角丸
- 流動的なライン
- 自然界からのインスピレーション
**Use cases:**
- ブランド要素
- ヒーローセクション
- イラストレーション
---
## Implementation Guidelines
### When to Use M3 Expressive
#### Good Use Cases ✓
- **Consumer apps**: エンターテイメント、ソーシャル、ゲーム
- **Brand-forward products**: ブランド表現が重要
- **Engagement-critical flows**: オンボーディング、チュートリアル
- **Hero moments**: 重要なマイルストーン、達成
#### Use with Caution ⚠
- **Productivity apps**: 過度なアニメーションは避ける
- **Frequent actions**: 繰り返し使用される操作
- **Data-heavy interfaces**: 情報が優先される場合
#### Avoid ✗
- **Accessibility concerns**: 動きに敏感なユーザー
- **Performance-constrained**: 低スペックデバイス
- **Critical tasks**: エラーや警告の表示
### Balancing Expressiveness and Usability
#### The 80/20 Rule
- **80%**: 標準のM3(速く、機能的)
- **20%**: M3 Expressive(印象的、ブランド表現)
**Example distribution:**
- Standard M3: リスト項目タップ、フォーム入力、設定変更
- M3 Expressive: 画面遷移、主要アクション(FAB)、初回体験
### Respect User Preferences
#### Reduced Motion
`prefers-reduced-motion`メディアクエリを尊重:
```css
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
```
#### Accessibility
- **Vestibular disorders**: 大きい動きを避ける
- **Cognitive load**: 同時に動く要素を制限
- **Focus management**: アニメーション中もフォーカス可能
---
## Practical Examples
### Example 1: Expressive FAB Tap
```css
.fab {
transition: transform 150ms cubic-bezier(0.05, 0.7, 0.1, 1.0),
box-shadow 150ms cubic-bezier(0.05, 0.7, 0.1, 1.0);
}
.fab:active {
transform: scale(0.92);
}
.fab:not(:active) {
transform: scale(1.0);
}
/* Ripple with longer duration */
.fab::after {
animation: ripple 600ms cubic-bezier(0.05, 0.7, 0.1, 1.0);
}
```
### Example 2: Card to Detail Transition
```javascript
// Container transform with expressive timing
const expandCard = (card) => {
card.animate([
{
transform: 'scale(1)',
borderRadius: '12px'
},
{
transform: 'scale(1.02)',
borderRadius: '28px',
offset: 0.3
},
{
transform: 'scale(1)',
borderRadius: '0px'
}
], {
duration: 500,
easing: 'cubic-bezier(0.05, 0.7, 0.1, 1.0)',
fill: 'forwards'
});
};
```
### Example 3: Staggered List Animation
```css
.list-item {
opacity: 0;
transform: translateY(20px);
animation: fadeInUp 400ms cubic-bezier(0.05, 0.7, 0.1, 1.0) forwards;
}
.list-item:nth-child(1) { animation-delay: 0ms; }
.list-item:nth-child(2) { animation-delay: 80ms; }
.list-item:nth-child(3) { animation-delay: 160ms; }
.list-item:nth-child(4) { animation-delay: 240ms; }
@keyframes fadeInUp {
to {
opacity: 1;
transform: translateY(0);
}
}
```
---
## Resources and Tools
### Design Tools
- **Material Theme Builder**: M3 Expressiveモーションプリセット
- **Figma Plugins**: Motion timing visualization
- **After Effects**: プロトタイプアニメーション
### Code Libraries
- **Web**: Material Web Components (M3 support)
- **Flutter**: Material 3 with custom motion
- **Android**: Jetpack Compose Material3
### References
- M3 Expressive announcement: https://m3.material.io/blog/building-with-m3-expressive
- Motion theming: https://m3.material.io/blog/m3-expressive-motion-theming
- Usability tactics: https://m3.material.io/foundations/usability/applying-m-3-expressive
---
## Summary Checklist
When implementing M3 Expressive, ensure:
- [ ] Emphasized easing for key transitions
- [ ] Extended durations (but <1000ms)
- [ ] Exaggerated scale changes where appropriate
- [ ] Layered/staggered animations for lists
- [ ] Shape morphing for container transforms
- [ ] Color dynamics for feedback
- [ ] Respect `prefers-reduced-motion`
- [ ] 80/20 balance (Standard M3 vs Expressive)
- [ ] Test on lower-end devices
- [ ] Validate accessibility
@@ -0,0 +1,318 @@
# Material 3 Styles
Material 3 Stylesは、カラー、タイポグラフィ、形状、エレベーション、アイコン、モーションを通じて視覚言語を定義します。
## Table of Contents
1. [Color](#color)
2. [Typography](#typography)
3. [Elevation](#elevation)
4. [Shape](#shape)
5. [Icons](#icons)
6. [Motion](#motion)
---
## Color
### Color System Overview
Material 3のカラーシステムは、アクセシブルでパーソナライズ可能なカラースキームを作成します。
URL: https://m3.material.io/styles/color/system/overview
### Color Roles
UIエレメントを特定の色に結びつける役割:
#### Primary Colors
- **primary**: アプリの主要色(メインボタン、アクティブ状態)
- **onPrimary**: プライマリ色上のテキスト/アイコン
- **primaryContainer**: プライマリ要素のコンテナ
- **onPrimaryContainer**: コンテナ上のテキスト
#### Secondary & Tertiary
- **secondary**: アクセントカラー
- **tertiary**: 強調やバランス調整
#### Surface Colors
- **surface**: カード、シート、メニューの背景
- **surfaceVariant**: わずかに異なる背景
- **surfaceTint**: エレベーション表現用
#### Semantic Colors
- **error**: エラー状態
- **warning**: 警告(一部実装で利用可能)
- **success**: 成功状態(一部実装で利用可能)
URL: https://m3.material.io/styles/color/roles
### Color Schemes
#### Dynamic Color
ユーザーの壁紙や選択から色を抽出:
- **User-generated**: ユーザーの選択から
- **Content-based**: 画像/コンテンツから抽出
URL: https://m3.material.io/styles/color/dynamic-color/overview
#### Static Color
固定されたカラースキーム:
- **Baseline**: デフォルトのMaterialベースライン
- **Custom brand**: カスタムブランドカラー
URL: https://m3.material.io/styles/color/static/baseline
### Key Colors and Tones
- **Source color**: スキーム生成の起点となる色
- **Tonal palette**: 各キーカラーから生成される13段階のトーン(0, 10, 20, ..., 100
- Light theme: 通常トーン40をプライマリに使用
- Dark theme: 通常トーン80をプライマリに使用
URL: https://m3.material.io/styles/color/the-color-system/key-colors-tones
### Tools
**Material Theme Builder**: カラースキーム生成、カスタマイズ、エクスポートツール
URL: https://m3.material.io/blog/material-theme-builder-2-color-match
---
## Typography
### Type Scale
Material 3は5つのロール×3つのサイズ = 15のタイプスタイルを定義:
#### Roles
1. **Display**: 大きく短いテキスト(ヒーロー、見出し)
2. **Headline**: 中規模の見出し
3. **Title**: 小さい見出し(アプリバー、リスト項目)
4. **Body**: 本文テキスト
5. **Label**: ボタン、タブ、小さいテキスト
#### Sizes
- **Large**: 最大サイズ
- **Medium**: 標準サイズ
- **Small**: 最小サイズ
#### Example Styles
```
displayLarge: 57sp, -0.25 letter spacing
headlineMedium: 28sp, 0 letter spacing
bodyLarge: 16sp, 0.5 letter spacing
labelSmall: 11sp, 0.5 letter spacing
```
URL: https://m3.material.io/styles/typography/overview
### Fonts
- デフォルト: **Roboto** (Android), **San Francisco** (iOS), **Roboto** (Web)
- カスタムフォントのサポート
- 変数フォントの活用
URL: https://m3.material.io/styles/typography/fonts
### Applying Typography
- セマンティックな使用(見出しにはheadline、本文にはbody
- 一貫した階層
- 行の高さと余白の適切な設定
URL: https://m3.material.io/styles/typography/applying-type
---
## Elevation
### Overview
エレベーションはZ軸上のサーフェス間の距離を表現します。
URL: https://m3.material.io/styles/elevation/overview
### Elevation Levels
Material 3は6つのエレベーションレベルを定義:
| Level | DP | Use Case |
|-------|-----|----------|
| 0 | 0dp | 通常のサーフェス |
| 1 | 1dp | カード、わずかに浮いた要素 |
| 2 | 3dp | 検索バー |
| 3 | 6dp | FAB(休止状態) |
| 4 | 8dp | ナビゲーションドロワー |
| 5 | 12dp | モーダルボトムシート、ダイアログ |
### Elevation Representation
Material 3では2つの方法でエレベーションを表現:
1. **Shadow**: 影によるエレベーション(Light theme主体)
2. **Surface tint**: サーフェスに色のティントを重ねる(Dark theme主体)
URL: https://m3.material.io/styles/elevation/applying-elevation
---
## Shape
### Overview
形状は、注意の誘導、状態表現、ブランド表現に使用されます。
URL: https://m3.material.io/styles/shape/overview-principles
### Corner Radius Scale
Material 3は5つの形状トークンを定義:
| Token | Default Value | Use Case |
|-------|---------------|----------|
| None | 0dp | フルスクリーン、厳格なレイアウト |
| Extra Small | 4dp | チェックボックス、小さい要素 |
| Small | 8dp | チップ、小さいボタン |
| Medium | 12dp | カード、標準ボタン |
| Large | 16dp | FAB、大きいカード |
| Extra Large | 28dp | ダイアログ、ボトムシート |
| Full | 9999dp | 完全な円形 |
### Shape Morph
**M3 Expressiveの重要機能**: 形状が滑らかに変形するアニメーション
- トランジション時の視覚的な流れ
- ブランド表現の強化
- ユーザーの注意を引く
URL: https://m3.material.io/styles/shape/shape-morph
---
## Icons
### Material Symbols
Material Symbolsは可変アイコンフォント:
#### Styles
- **Outlined**: 線のみのスタイル(デフォルト)
- **Filled**: 塗りつぶしスタイル
- **Rounded**: 丸みを帯びたスタイル
- **Sharp**: シャープなスタイル
#### Variable Features
- **Weight**: 線の太さ(100-700
- **Grade**: 視覚的な重み(-25 to 200
- **Optical size**: 表示サイズ最適化(20, 24, 40, 48dp
- **Fill**: 塗りつぶし状態(0-1
#### Sizes
- 20dp: 密なレイアウト
- 24dp: 標準サイズ
- 40dp: タッチターゲット拡大
- 48dp: 大きいタッチターゲット
URL: https://m3.material.io/styles/icons/overview
### Custom Icons
カスタムアイコンのデザインガイドライン:
- 24×24dpグリッド
- 2dpストローク幅
- 2dpの角丸
- 一貫したメタファー
URL: https://m3.material.io/styles/icons/designing-icons
---
## Motion
**M3 Expressiveの中核要素**: モーションは、UIを表現豊かで使いやすくします。
URL: https://m3.material.io/styles/motion/overview
### Motion Principles
1. **Informative**: ユーザーに情報を伝える
2. **Focused**: 注意を適切に誘導
3. **Expressive**: 感情的なエンゲージメントを高める
URL: https://m3.material.io/styles/motion/overview/how-it-works
### Easing and Duration
#### Easing Types
Material 3は4つのイージングカーブを定義:
1. **Emphasized**: 劇的で表現豊かな動き
- Decelerate: cubic-bezier(0.05, 0.7, 0.1, 1.0)
- Accelerate: cubic-bezier(0.3, 0.0, 0.8, 0.15)
- Standard: cubic-bezier(0.2, 0.0, 0, 1.0)
2. **Standard**: バランスの取れた標準的な動き
- cubic-bezier(0.2, 0.0, 0, 1.0)
3. **Emphasized Decelerate**: 要素が画面に入る
- cubic-bezier(0.05, 0.7, 0.1, 1.0)
4. **Emphasized Accelerate**: 要素が画面から出る
- cubic-bezier(0.3, 0.0, 0.8, 0.15)
#### Duration Guidelines
| Element Change | Duration |
|----------------|----------|
| Small (icon state) | 50-100ms |
| Medium (component state) | 250-300ms |
| Large (layout change) | 400-500ms |
| Complex transition | 500-700ms |
**重要**: 長すぎるアニメーション(>1000ms)は避ける
URL: https://m3.material.io/styles/motion/easing-and-duration
### Transitions
ナビゲーション時のトランジションパターン:
#### Transition Types
1. **Container transform**: コンテナが変形して次の画面へ
2. **Shared axis**: 共通軸に沿った移動(X, Y, Z軸)
3. **Fade through**: フェードアウト→フェードイン
4. **Fade**: シンプルなフェード
#### When to Use Each
- **Container transform**: リスト項目→詳細画面
- **Shared axis X**: タブ切り替え、水平ナビゲーション
- **Shared axis Y**: ステッパー、垂直ナビゲーション
- **Shared axis Z**: 前後のナビゲーション(戻る/進む)
- **Fade through**: コンテンツ更新(関連性が低い)
- **Fade**: オーバーレイ、補助的な変更
URL: https://m3.material.io/styles/motion/transitions/transition-patterns
### M3 Expressive Motion
**新しい表現豊かなモーションシステム**:
- より大胆なアニメーション
- カスタマイズ可能なモーションテーマ
- ブランド表現の強化
URL: https://m3.material.io/blog/m3-expressive-motion-theming
---
## References
- Material Design 3 Styles: https://m3.material.io/styles/
- Material Theme Builder: https://material-foundation.github.io/material-theme-builder/
- Material Symbols: https://fonts.google.com/icons
@@ -0,0 +1,201 @@
---
name: "moai-platform-supabase"
description: "Supabase specialist covering PostgreSQL 16, pgvector, RLS, real-time subscriptions, and Edge Functions. Use when building full-stack apps with Supabase backend."
version: 2.0.0
category: "platform"
modularized: true
tags: ['supabase', 'postgresql', 'pgvector', 'realtime', 'rls', 'edge-functions']
context7-libraries: ['/supabase/supabase']
related-skills: ['moai-platform-neon', 'moai-lang-typescript']
updated: 2026-01-06
status: "active"
allowed-tools: "Read, Grep, Glob, mcp__context7__resolve-library-id, mcp__context7__get-library-docs"
---
# moai-platform-supabase: Supabase Platform Specialist
## Quick Reference (30 seconds)
Supabase Full-Stack Platform: PostgreSQL 16 with pgvector for AI/vector search, Row-Level Security for multi-tenant apps, real-time subscriptions, Edge Functions with Deno runtime, and integrated Storage with transformations.
### Core Capabilities
PostgreSQL 16: Latest PostgreSQL with full SQL support, JSONB, and advanced features
pgvector Extension: AI embeddings storage with HNSW/IVFFlat indexes for similarity search
Row-Level Security: Automatic multi-tenant data isolation at database level
Real-time Subscriptions: Live data sync via Postgres Changes and Presence
Edge Functions: Serverless Deno functions at the edge
Storage: File storage with automatic image transformations
Auth: Built-in authentication with JWT integration
### When to Use Supabase
- Multi-tenant SaaS applications requiring data isolation
- AI/ML applications needing vector embeddings and similarity search
- Real-time collaborative features (presence, live updates)
- Full-stack applications needing auth, database, and storage
- Projects requiring PostgreSQL-specific features
### Context7 Documentation Access
For latest Supabase API documentation, use the Context7 MCP tools:
Step 1 - Resolve library ID:
Use mcp__context7__resolve-library-id with query "supabase" to get the Context7-compatible library ID
Step 2 - Fetch documentation:
Use mcp__context7__get-library-docs with the resolved library ID, specifying topic and token allocation
Example topics: "postgresql pgvector", "row-level-security policies", "realtime subscriptions presence", "edge-functions deno", "storage transformations", "auth jwt"
---
## Module Index
This skill uses progressive disclosure with specialized modules for detailed implementation patterns.
### Core Modules
**postgresql-pgvector** - PostgreSQL 16 with pgvector extension for AI embeddings and semantic search
- Vector storage with 1536-dimension OpenAI embeddings
- HNSW and IVFFlat index strategies
- Semantic search functions
- Hybrid search combining vector and full-text
**row-level-security** - RLS policies for multi-tenant data isolation
- Basic tenant isolation patterns
- Hierarchical organization access
- Role-based modification policies
- Service role bypass for server operations
**realtime-presence** - Real-time subscriptions and presence tracking
- Postgres Changes subscription patterns
- Filtered change listeners
- Presence state management
- Collaborative cursor and typing indicators
**edge-functions** - Serverless Deno functions at the edge
- Basic Edge Function with authentication
- CORS header configuration
- JWT token verification
- Rate limiting implementation
**storage-cdn** - File storage with image transformations
- File upload patterns
- Image transformation URLs
- Thumbnail generation
- Cache control configuration
**auth-integration** - Authentication patterns and JWT handling
- Server-side client creation
- Cookie-based session management
- Auth state synchronization
- Protected route patterns
**typescript-patterns** - TypeScript client patterns and service layers
- Server-side client for Next.js App Router
- Service layer abstraction pattern
- Subscription management
- Type-safe database operations
---
## Quick Start Patterns
### Database Setup
Enable pgvector extension and create embeddings table:
```sql
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
content TEXT NOT NULL,
embedding vector(1536),
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_documents_embedding ON documents
USING hnsw (embedding vector_cosine_ops);
```
### Basic RLS Policy
```sql
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
CREATE POLICY "tenant_isolation" ON projects FOR ALL
USING (tenant_id = (auth.jwt() ->> 'tenant_id')::UUID);
```
### Real-time Subscription
```typescript
const channel = supabase.channel('db-changes')
.on('postgres_changes',
{ event: '*', schema: 'public', table: 'messages' },
(payload) => console.log('Change:', payload)
)
.subscribe()
```
### Edge Function Template
```typescript
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
serve(async (req) => {
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
)
// Process request
return new Response(JSON.stringify({ success: true }))
})
```
---
## Best Practices
Performance: Use HNSW indexes for vectors, Supavisor for connection pooling in serverless
Security: Always enable RLS, verify JWT tokens, use service_role only in Edge Functions
Migration: Use Supabase CLI (supabase migration new, supabase db push)
---
## Works Well With
- moai-platform-neon - Alternative PostgreSQL for specific use cases
- moai-lang-typescript - TypeScript patterns for Supabase client
- moai-domain-backend - Backend architecture integration
- moai-foundation-quality - Security and RLS best practices
- moai-workflow-testing - Test-driven development with Supabase
---
## Module References
For detailed implementation patterns, see the modules directory:
- modules/postgresql-pgvector.md - Complete vector search implementation
- modules/row-level-security.md - Multi-tenant RLS patterns
- modules/realtime-presence.md - Real-time collaboration features
- modules/edge-functions.md - Serverless function patterns
- modules/storage-cdn.md - File storage and transformations
- modules/auth-integration.md - Authentication patterns
- modules/typescript-patterns.md - TypeScript client architecture
For API reference summary, see reference.md
For full-stack templates, see examples.md
---
Status: Production Ready
Generated with: MoAI-ADK Skill Factory v2.0
Last Updated: 2026-01-06
Version: 2.0.0 (Modularized)
Coverage: PostgreSQL 16, pgvector, RLS, Real-time, Edge Functions, Storage
@@ -0,0 +1,502 @@
---
name: supabase-examples
description: Full-stack templates and working examples for Supabase applications
parent-skill: moai-platform-supabase
version: 1.0.0
updated: 2026-01-06
---
# Supabase Full-Stack Examples
## Multi-Tenant SaaS Application
### Database Schema
```sql
-- Organizations (tenants)
CREATE TABLE organizations (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL,
plan TEXT DEFAULT 'free' CHECK (plan IN ('free', 'pro', 'enterprise')),
settings JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Organization members with roles
CREATE TABLE organization_members (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
organization_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
user_id UUID NOT NULL,
role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'member', 'viewer')),
joined_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(organization_id, user_id)
);
-- Projects within organizations
CREATE TABLE projects (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
organization_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
owner_id UUID NOT NULL,
status TEXT DEFAULT 'active',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Enable RLS
ALTER TABLE organizations ENABLE ROW LEVEL SECURITY;
ALTER TABLE organization_members ENABLE ROW LEVEL SECURITY;
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
-- Policies
CREATE POLICY "org_member_select" ON organizations FOR SELECT
USING (id IN (SELECT organization_id FROM organization_members WHERE user_id = auth.uid()));
CREATE POLICY "org_admin_update" ON organizations FOR UPDATE
USING (id IN (SELECT organization_id FROM organization_members
WHERE user_id = auth.uid() AND role IN ('owner', 'admin')));
CREATE POLICY "member_view" ON organization_members FOR SELECT
USING (organization_id IN (SELECT organization_id FROM organization_members WHERE user_id = auth.uid()));
CREATE POLICY "project_access" ON projects FOR ALL
USING (organization_id IN (SELECT organization_id FROM organization_members WHERE user_id = auth.uid()));
-- Indexes
CREATE INDEX idx_org_members_user ON organization_members(user_id);
CREATE INDEX idx_org_members_org ON organization_members(organization_id);
CREATE INDEX idx_projects_org ON projects(organization_id);
```
### TypeScript Service Layer
```typescript
// services/organization-service.ts
import { supabase } from '@/lib/supabase/client'
import type { Database } from '@/types/database'
type Organization = Database['public']['Tables']['organizations']['Row']
type OrganizationMember = Database['public']['Tables']['organization_members']['Row']
export class OrganizationService {
async create(name: string, slug: string): Promise<Organization> {
const { data: { user } } = await supabase.auth.getUser()
if (!user) throw new Error('Not authenticated')
// Create organization
const { data: org, error: orgError } = await supabase
.from('organizations')
.insert({ name, slug })
.select()
.single()
if (orgError) throw orgError
// Add creator as owner
const { error: memberError } = await supabase
.from('organization_members')
.insert({
organization_id: org.id,
user_id: user.id,
role: 'owner'
})
if (memberError) {
// Rollback org creation
await supabase.from('organizations').delete().eq('id', org.id)
throw memberError
}
return org
}
async getMyOrganizations(): Promise<Organization[]> {
const { data, error } = await supabase
.from('organizations')
.select('*, organization_members!inner(role)')
.order('name')
if (error) throw error
return data
}
async getMembers(orgId: string): Promise<OrganizationMember[]> {
const { data, error } = await supabase
.from('organization_members')
.select('*, user:profiles(*)')
.eq('organization_id', orgId)
.order('joined_at')
if (error) throw error
return data
}
async inviteMember(orgId: string, email: string, role: string): Promise<void> {
const { error } = await supabase.functions.invoke('invite-member', {
body: { organizationId: orgId, email, role }
})
if (error) throw error
}
}
export const organizationService = new OrganizationService()
```
## AI Document Search Application
### Database Schema
```sql
-- Enable extensions
CREATE EXTENSION IF NOT EXISTS vector;
-- Documents with embeddings
CREATE TABLE documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
title TEXT NOT NULL,
content TEXT NOT NULL,
embedding vector(1536),
metadata JSONB DEFAULT '{}',
created_by UUID NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- HNSW index for fast similarity search
CREATE INDEX idx_documents_embedding ON documents
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
-- Full-text search index
CREATE INDEX idx_documents_content_fts ON documents
USING gin(to_tsvector('english', content));
-- Enable RLS
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
CREATE POLICY "document_access" ON documents FOR ALL
USING (project_id IN (
SELECT p.id FROM projects p
JOIN organization_members om ON p.organization_id = om.organization_id
WHERE om.user_id = auth.uid()
));
-- Semantic search function
CREATE OR REPLACE FUNCTION search_documents(
p_project_id UUID,
p_query_embedding vector(1536),
p_match_threshold FLOAT DEFAULT 0.7,
p_match_count INT DEFAULT 10
) RETURNS TABLE (
id UUID,
title TEXT,
content TEXT,
similarity FLOAT
) LANGUAGE plpgsql AS $$
BEGIN
RETURN QUERY
SELECT d.id, d.title, d.content,
1 - (d.embedding <=> p_query_embedding) AS similarity
FROM documents d
WHERE d.project_id = p_project_id
AND 1 - (d.embedding <=> p_query_embedding) > p_match_threshold
ORDER BY d.embedding <=> p_query_embedding
LIMIT p_match_count;
END; $$;
```
### Edge Function for Embeddings
```typescript
// supabase/functions/generate-embedding/index.ts
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type'
}
serve(async (req) => {
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders })
}
try {
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
)
const { documentId, content } = await req.json()
// Generate embedding using OpenAI
const embeddingResponse = await fetch('https://api.openai.com/v1/embeddings', {
method: 'POST',
headers: {
'Authorization': `Bearer ${Deno.env.get('OPENAI_API_KEY')}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: 'text-embedding-ada-002',
input: content.slice(0, 8000)
})
})
const embeddingData = await embeddingResponse.json()
const embedding = embeddingData.data[0].embedding
// Update document with embedding
const { error } = await supabase
.from('documents')
.update({ embedding })
.eq('id', documentId)
if (error) throw error
return new Response(
JSON.stringify({ success: true }),
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
} catch (error) {
return new Response(
JSON.stringify({ error: error.message }),
{ status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
}
})
```
### React Search Component
```typescript
// components/DocumentSearch.tsx
'use client'
import { useState, useEffect } from 'react'
import { useDebounce } from '@/hooks/useDebounce'
import { documentService } from '@/services/document-service'
interface SearchResult {
id: string
title: string
content: string
similarity: number
}
export function DocumentSearch({ projectId }: { projectId: string }) {
const [query, setQuery] = useState('')
const [results, setResults] = useState<SearchResult[]>([])
const [loading, setLoading] = useState(false)
const debouncedQuery = useDebounce(query, 300)
useEffect(() => {
if (debouncedQuery.length < 3) {
setResults([])
return
}
async function search() {
setLoading(true)
try {
const data = await documentService.semanticSearch(projectId, debouncedQuery)
setResults(data)
} catch (error) {
console.error('Search failed:', error)
} finally {
setLoading(false)
}
}
search()
}, [debouncedQuery, projectId])
return (
<div className="space-y-4">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search documents..."
className="w-full px-4 py-2 border rounded-lg"
/>
{loading && <div>Searching...</div>}
<div className="space-y-2">
{results.map((result) => (
<div key={result.id} className="p-4 border rounded-lg">
<div className="flex justify-between">
<h3 className="font-medium">{result.title}</h3>
<span className="text-sm text-gray-500">
{(result.similarity * 100).toFixed(1)}% match
</span>
</div>
<p className="mt-2 text-gray-600 line-clamp-2">{result.content}</p>
</div>
))}
</div>
</div>
)
}
```
## Real-Time Collaboration
### Collaborative Editor with Presence
```typescript
// components/CollaborativeEditor.tsx
'use client'
import { useEffect, useState, useCallback } from 'react'
import { supabase } from '@/lib/supabase/client'
interface User {
id: string
name: string
color: string
}
interface PresenceState {
user: User
cursor: { x: number; y: number } | null
selection: { start: number; end: number } | null
}
export function CollaborativeEditor({
documentId,
currentUser
}: {
documentId: string
currentUser: User
}) {
const [content, setContent] = useState('')
const [otherUsers, setOtherUsers] = useState<PresenceState[]>([])
const [channel, setChannel] = useState<ReturnType<typeof supabase.channel> | null>(null)
useEffect(() => {
const ch = supabase.channel(`doc:${documentId}`, {
config: { presence: { key: currentUser.id } }
})
ch.on('presence', { event: 'sync' }, () => {
const state = ch.presenceState<PresenceState>()
const users = Object.values(state)
.flat()
.filter((p) => p.user.id !== currentUser.id)
setOtherUsers(users)
})
ch.on('broadcast', { event: 'content-update' }, ({ payload }) => {
if (payload.userId !== currentUser.id) {
setContent(payload.content)
}
})
ch.subscribe(async (status) => {
if (status === 'SUBSCRIBED') {
await ch.track({
user: currentUser,
cursor: null,
selection: null
})
}
})
setChannel(ch)
return () => {
supabase.removeChannel(ch)
}
}, [documentId, currentUser])
const handleContentChange = useCallback(
async (newContent: string) => {
setContent(newContent)
if (channel) {
await channel.send({
type: 'broadcast',
event: 'content-update',
payload: { userId: currentUser.id, content: newContent }
})
}
},
[channel, currentUser.id]
)
return (
<div className="relative">
<textarea
value={content}
onChange={(e) => handleContentChange(e.target.value)}
className="w-full h-96 p-4 border rounded-lg"
/>
<div className="mt-4 flex gap-2">
<span className="text-sm text-gray-500">Active:</span>
{otherUsers.map((presence) => (
<span
key={presence.user.id}
className="px-2 py-1 text-xs rounded-full"
style={{ backgroundColor: presence.user.color + '20', color: presence.user.color }}
>
{presence.user.name}
</span>
))}
</div>
</div>
)
}
```
## Project Structure Template
```
my-supabase-app/
├── supabase/
│ ├── functions/
│ │ ├── generate-embedding/
│ │ │ └── index.ts
│ │ └── invite-member/
│ │ └── index.ts
│ ├── migrations/
│ │ ├── 20240101000000_initial_schema.sql
│ │ └── 20240101000001_add_embeddings.sql
│ └── config.toml
├── src/
│ ├── lib/
│ │ └── supabase/
│ │ ├── client.ts
│ │ └── server.ts
│ ├── services/
│ │ ├── organization-service.ts
│ │ ├── project-service.ts
│ │ └── document-service.ts
│ ├── types/
│ │ └── database.ts
│ └── components/
│ ├── DocumentSearch.tsx
│ ├── CollaborativeEditor.tsx
│ └── FileUploader.tsx
├── .env.local
└── package.json
```
## Environment Configuration
```bash
# .env.local
NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
# Server-side only
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
# Edge Functions secrets (set via CLI)
# supabase secrets set OPENAI_API_KEY=sk-...
```
@@ -0,0 +1,384 @@
---
name: auth-integration
description: Authentication patterns and JWT handling for Supabase applications
parent-skill: moai-platform-supabase
version: 1.0.0
updated: 2026-01-06
---
# Auth Integration Module
## Overview
Supabase Auth provides authentication with multiple providers, JWT-based sessions, and seamless integration with Row-Level Security policies.
## Client Setup
### Browser Client
```typescript
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
```
### Server-Side Client (Next.js App Router)
```typescript
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
import { Database } from './database.types'
export function createServerSupabase() {
const cookieStore = cookies()
return createServerClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
get(name: string) {
return cookieStore.get(name)?.value
},
set(name, value, options) {
cookieStore.set({ name, value, ...options })
},
remove(name, options) {
cookieStore.set({ name, value: '', ...options })
}
}
}
)
}
```
### Middleware Client (Next.js)
```typescript
// middleware.ts
import { createServerClient, type CookieOptions } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
export async function middleware(request: NextRequest) {
let response = NextResponse.next({
request: { headers: request.headers }
})
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
get(name: string) {
return request.cookies.get(name)?.value
},
set(name: string, value: string, options: CookieOptions) {
request.cookies.set({ name, value, ...options })
response.cookies.set({ name, value, ...options })
},
remove(name: string, options: CookieOptions) {
request.cookies.set({ name, value: '', ...options })
response.cookies.set({ name, value: '', ...options })
}
}
}
)
await supabase.auth.getUser()
return response
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)']
}
```
## Authentication Methods
### Email/Password Sign Up
```typescript
async function signUp(email: string, password: string) {
const { data, error } = await supabase.auth.signUp({
email,
password,
options: {
emailRedirectTo: `${window.location.origin}/auth/callback`
}
})
if (error) throw error
return data
}
```
### Email/Password Sign In
```typescript
async function signIn(email: string, password: string) {
const { data, error } = await supabase.auth.signInWithPassword({
email,
password
})
if (error) throw error
return data
}
```
### OAuth Provider
```typescript
async function signInWithGoogle() {
const { data, error } = await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo: `${window.location.origin}/auth/callback`,
queryParams: {
access_type: 'offline',
prompt: 'consent'
}
}
})
if (error) throw error
return data
}
```
### Magic Link
```typescript
async function signInWithMagicLink(email: string) {
const { data, error } = await supabase.auth.signInWithOtp({
email,
options: {
emailRedirectTo: `${window.location.origin}/auth/callback`
}
})
if (error) throw error
return data
}
```
## Session Management
### Get Current User
```typescript
async function getCurrentUser() {
const { data: { user }, error } = await supabase.auth.getUser()
if (error) throw error
return user
}
```
### Get Session
```typescript
async function getSession() {
const { data: { session }, error } = await supabase.auth.getSession()
if (error) throw error
return session
}
```
### Sign Out
```typescript
async function signOut() {
const { error } = await supabase.auth.signOut()
if (error) throw error
}
```
### Listen to Auth Changes
```typescript
useEffect(() => {
const { data: { subscription } } = supabase.auth.onAuthStateChange(
(event, session) => {
if (event === 'SIGNED_IN') {
console.log('User signed in:', session?.user)
}
if (event === 'SIGNED_OUT') {
console.log('User signed out')
}
if (event === 'TOKEN_REFRESHED') {
console.log('Token refreshed')
}
}
)
return () => subscription.unsubscribe()
}, [])
```
## Auth Callback Handler
### Next.js App Router
```typescript
// app/auth/callback/route.ts
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
import { NextResponse, type NextRequest } from 'next/server'
export async function GET(request: NextRequest) {
const { searchParams, origin } = new URL(request.url)
const code = searchParams.get('code')
const next = searchParams.get('next') ?? '/'
if (code) {
const cookieStore = cookies()
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
get(name: string) {
return cookieStore.get(name)?.value
},
set(name, value, options) {
cookieStore.set({ name, value, ...options })
},
remove(name, options) {
cookieStore.set({ name, value: '', ...options })
}
}
}
)
const { error } = await supabase.auth.exchangeCodeForSession(code)
if (!error) {
return NextResponse.redirect(`${origin}${next}`)
}
}
return NextResponse.redirect(`${origin}/auth/error`)
}
```
## Protected Routes
### Server Component Protection
```typescript
// app/dashboard/page.tsx
import { redirect } from 'next/navigation'
import { createServerSupabase } from '@/lib/supabase/server'
export default async function DashboardPage() {
const supabase = createServerSupabase()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
redirect('/login')
}
return <Dashboard user={user} />
}
```
### Client Component Protection
```typescript
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { supabase } from '@/lib/supabase/client'
export function useRequireAuth() {
const [user, setUser] = useState(null)
const [loading, setLoading] = useState(true)
const router = useRouter()
useEffect(() => {
supabase.auth.getUser().then(({ data: { user } }) => {
if (!user) {
router.push('/login')
} else {
setUser(user)
}
setLoading(false)
})
}, [router])
return { user, loading }
}
```
## Custom Claims
### Setting Custom Claims (Edge Function)
```typescript
// supabase/functions/set-claims/index.ts
serve(async (req) => {
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
)
const { userId, claims } = await req.json()
// Update user metadata (available in JWT)
const { error } = await supabase.auth.admin.updateUserById(userId, {
app_metadata: claims
})
if (error) throw error
return new Response(JSON.stringify({ success: true }))
})
```
### Reading Claims in RLS
```sql
-- Access claims in RLS policies
CREATE POLICY "admin_only" ON admin_data FOR ALL
USING ((auth.jwt() ->> 'role')::text = 'admin');
```
## Password Reset
```typescript
async function resetPassword(email: string) {
const { data, error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: `${window.location.origin}/auth/reset-password`
})
if (error) throw error
return data
}
async function updatePassword(newPassword: string) {
const { data, error } = await supabase.auth.updateUser({
password: newPassword
})
if (error) throw error
return data
}
```
## Context7 Query Examples
For latest Auth documentation:
Topic: "supabase auth signIn signUp"
Topic: "supabase ssr server client"
Topic: "auth jwt claims custom"
---
Related Modules:
- row-level-security.md - Auth integration with RLS
- typescript-patterns.md - Type-safe auth patterns
- edge-functions.md - Server-side auth verification
@@ -0,0 +1,371 @@
---
name: edge-functions
description: Serverless Deno functions at the edge with authentication and rate limiting
parent-skill: moai-platform-supabase
version: 1.0.0
updated: 2026-01-06
---
# Edge Functions Module
## Overview
Supabase Edge Functions are serverless functions running on the Deno runtime at the edge, providing low-latency responses globally.
## Basic Edge Function
### Function Structure
```typescript
// supabase/functions/api/index.ts
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type'
}
serve(async (req) => {
// Handle CORS preflight
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders })
}
try {
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
)
// Process request
const body = await req.json()
return new Response(
JSON.stringify({ success: true, data: body }),
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
} catch (error) {
return new Response(
JSON.stringify({ error: error.message }),
{ status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
}
})
```
## Authentication
### JWT Token Verification
```typescript
serve(async (req) => {
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders })
}
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
)
// Verify JWT token
const authHeader = req.headers.get('authorization')
if (!authHeader) {
return new Response(
JSON.stringify({ error: 'Unauthorized' }),
{ status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
}
const { data: { user }, error } = await supabase.auth.getUser(
authHeader.replace('Bearer ', '')
)
if (error || !user) {
return new Response(
JSON.stringify({ error: 'Invalid token' }),
{ status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
}
// User is authenticated, proceed with request
return new Response(
JSON.stringify({ success: true, user_id: user.id }),
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
})
```
### Using User Context Client
Create a client that inherits user permissions:
```typescript
serve(async (req) => {
const authHeader = req.headers.get('authorization')!
// Client with user's permissions (respects RLS)
const supabaseUser = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_ANON_KEY')!,
{ global: { headers: { Authorization: authHeader } } }
)
// This query respects RLS policies
const { data, error } = await supabaseUser
.from('projects')
.select('*')
return new Response(JSON.stringify({ data }))
})
```
## Rate Limiting
### Database-Based Rate Limiting
```sql
-- Rate limits table
CREATE TABLE rate_limits (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
identifier TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_rate_limits_lookup ON rate_limits(identifier, created_at);
```
### Rate Limit Function
```typescript
async function checkRateLimit(
supabase: SupabaseClient,
identifier: string,
limit: number,
windowSeconds: number
): Promise<boolean> {
const windowStart = new Date(Date.now() - windowSeconds * 1000).toISOString()
const { count } = await supabase
.from('rate_limits')
.select('*', { count: 'exact', head: true })
.eq('identifier', identifier)
.gte('created_at', windowStart)
if (count && count >= limit) {
return false
}
await supabase.from('rate_limits').insert({ identifier })
return true
}
```
### Usage in Edge Function
```typescript
serve(async (req) => {
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
)
// Get client identifier (IP or user ID)
const identifier = req.headers.get('x-forwarded-for') || 'anonymous'
// 100 requests per minute
const allowed = await checkRateLimit(supabase, identifier, 100, 60)
if (!allowed) {
return new Response(
JSON.stringify({ error: 'Rate limit exceeded' }),
{ status: 429, headers: corsHeaders }
)
}
// Process request...
})
```
## External API Integration
### Webhook Handler
```typescript
serve(async (req) => {
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
)
// Verify webhook signature
const signature = req.headers.get('x-webhook-signature')
const body = await req.text()
const expectedSignature = await crypto.subtle.digest(
'SHA-256',
new TextEncoder().encode(body + Deno.env.get('WEBHOOK_SECRET'))
)
if (!verifySignature(signature, expectedSignature)) {
return new Response('Invalid signature', { status: 401 })
}
const payload = JSON.parse(body)
// Process webhook
await supabase.from('webhook_events').insert({
type: payload.type,
data: payload.data,
processed: false
})
return new Response('OK', { status: 200 })
})
```
### External API Call
```typescript
serve(async (req) => {
const { query } = await req.json()
// Call external API
const response = await fetch('https://api.openai.com/v1/embeddings', {
method: 'POST',
headers: {
'Authorization': `Bearer ${Deno.env.get('OPENAI_API_KEY')}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: 'text-embedding-ada-002',
input: query
})
})
const data = await response.json()
return new Response(
JSON.stringify({ embedding: data.data[0].embedding }),
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
})
```
## Error Handling
### Structured Error Response
```typescript
interface ErrorResponse {
error: string
code: string
details?: unknown
}
function errorResponse(
message: string,
code: string,
status: number,
details?: unknown
): Response {
const body: ErrorResponse = { error: message, code, details }
return new Response(
JSON.stringify(body),
{ status, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
}
serve(async (req) => {
try {
// ... processing
} catch (error) {
if (error instanceof AuthError) {
return errorResponse('Authentication failed', 'AUTH_ERROR', 401)
}
if (error instanceof ValidationError) {
return errorResponse('Invalid input', 'VALIDATION_ERROR', 400, error.details)
}
console.error('Unexpected error:', error)
return errorResponse('Internal server error', 'INTERNAL_ERROR', 500)
}
})
```
## Deployment
### Local Development
```bash
supabase functions serve api --env-file .env.local
```
### Deploy Function
```bash
supabase functions deploy api
```
### Set Secrets
```bash
supabase secrets set OPENAI_API_KEY=sk-xxx
supabase secrets set WEBHOOK_SECRET=whsec-xxx
```
### List Functions
```bash
supabase functions list
```
## Best Practices
### Cold Start Optimization
Keep imports minimal at the top level:
```typescript
// Good: Import only what's needed
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
// Bad: Heavy imports at top level increase cold start
// import { everything } from 'large-library'
```
### Response Streaming
Stream large responses:
```typescript
serve(async (req) => {
const stream = new ReadableStream({
async start(controller) {
for (let i = 0; i < 10; i++) {
await new Promise(r => setTimeout(r, 100))
controller.enqueue(new TextEncoder().encode(`data: ${i}\n\n`))
}
controller.close()
}
})
return new Response(stream, {
headers: { 'Content-Type': 'text/event-stream' }
})
})
```
## Context7 Query Examples
For latest Edge Functions documentation:
Topic: "edge functions deno runtime"
Topic: "supabase functions deploy secrets"
Topic: "edge functions cors authentication"
---
Related Modules:
- auth-integration.md - Authentication patterns
- typescript-patterns.md - Client invocation
@@ -0,0 +1,231 @@
---
name: postgresql-pgvector
description: PostgreSQL 16 with pgvector extension for AI embeddings and semantic search
parent-skill: moai-platform-supabase
version: 1.0.0
updated: 2026-01-06
---
# PostgreSQL 16 + pgvector Module
## Overview
PostgreSQL 16 with pgvector extension enables AI-powered semantic search through vector embeddings storage and similarity search operations.
## Extension Setup
Enable required extensions for vector operations:
```sql
-- Enable required extensions
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS vector;
```
## Embeddings Table Schema
Create a table optimized for storing AI embeddings:
```sql
CREATE TABLE documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
content TEXT NOT NULL,
embedding vector(1536), -- OpenAI ada-002 dimensions
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW()
);
```
### Common Embedding Dimensions
- OpenAI ada-002: 1536 dimensions
- OpenAI text-embedding-3-small: 1536 dimensions
- OpenAI text-embedding-3-large: 3072 dimensions
- Cohere embed-english-v3.0: 1024 dimensions
- Google PaLM: 768 dimensions
## Index Strategies
### HNSW Index (Recommended)
HNSW (Hierarchical Navigable Small World) provides fast approximate nearest neighbor search:
```sql
CREATE INDEX idx_documents_embedding ON documents
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
```
Parameters:
- m: Maximum number of connections per layer (default 16, higher = more accurate but slower)
- ef_construction: Size of dynamic candidate list during construction (default 64)
### IVFFlat Index (Large Datasets)
IVFFlat is better for datasets with millions of rows:
```sql
CREATE INDEX idx_documents_ivf ON documents
USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100);
```
Guidelines for lists parameter:
- Less than 1M rows: lists = rows / 1000
- More than 1M rows: lists = sqrt(rows)
## Distance Operations
Available distance operators:
- `<->` - Euclidean distance (L2)
- `<#>` - Negative inner product
- `<=>` - Cosine distance
For normalized embeddings, cosine distance is recommended.
## Semantic Search Function
Basic semantic search with threshold and limit:
```sql
CREATE OR REPLACE FUNCTION search_documents(
query_embedding vector(1536),
match_threshold FLOAT DEFAULT 0.8,
match_count INT DEFAULT 10
) RETURNS TABLE (id UUID, content TEXT, similarity FLOAT)
LANGUAGE plpgsql AS $$
BEGIN
RETURN QUERY SELECT d.id, d.content,
1 - (d.embedding <=> query_embedding) AS similarity
FROM documents d
WHERE 1 - (d.embedding <=> query_embedding) > match_threshold
ORDER BY d.embedding <=> query_embedding
LIMIT match_count;
END; $$;
```
### Usage Example
```sql
SELECT * FROM search_documents(
'[0.1, 0.2, ...]'::vector(1536),
0.75,
20
);
```
## Hybrid Search
Combine vector similarity with full-text search for better results:
```sql
CREATE OR REPLACE FUNCTION hybrid_search(
query_text TEXT,
query_embedding vector(1536),
match_count INT DEFAULT 10,
full_text_weight FLOAT DEFAULT 0.3,
semantic_weight FLOAT DEFAULT 0.7
) RETURNS TABLE (id UUID, content TEXT, score FLOAT) AS $$
BEGIN
RETURN QUERY
WITH semantic AS (
SELECT e.id, e.content, 1 - (e.embedding <=> query_embedding) AS similarity
FROM documents e ORDER BY e.embedding <=> query_embedding LIMIT match_count * 2
),
full_text AS (
SELECT e.id, e.content,
ts_rank(to_tsvector('english', e.content), plainto_tsquery('english', query_text)) AS rank
FROM documents e
WHERE to_tsvector('english', e.content) @@ plainto_tsquery('english', query_text)
LIMIT match_count * 2
)
SELECT COALESCE(s.id, f.id), COALESCE(s.content, f.content),
(COALESCE(s.similarity, 0) * semantic_weight + COALESCE(f.rank, 0) * full_text_weight)
FROM semantic s FULL OUTER JOIN full_text f ON s.id = f.id
ORDER BY 3 DESC LIMIT match_count;
END; $$ LANGUAGE plpgsql;
```
## Full-Text Search Index
Add GIN index for efficient full-text search:
```sql
CREATE INDEX idx_documents_content_fts ON documents
USING gin(to_tsvector('english', content));
```
## Performance Optimization
### Query Performance
Set appropriate ef_search for HNSW queries:
```sql
SET hnsw.ef_search = 100; -- Higher = more accurate, slower
```
### Batch Insertions
Use COPY or multi-row INSERT for bulk embeddings:
```sql
INSERT INTO documents (content, embedding, metadata)
VALUES
('Content 1', '[...]'::vector(1536), '{"source": "doc1"}'),
('Content 2', '[...]'::vector(1536), '{"source": "doc2"}'),
('Content 3', '[...]'::vector(1536), '{"source": "doc3"}');
```
### Index Maintenance
Reindex after large bulk insertions:
```sql
REINDEX INDEX CONCURRENTLY idx_documents_embedding;
```
## Metadata Filtering
Combine vector search with JSONB metadata filters:
```sql
CREATE OR REPLACE FUNCTION search_with_filters(
query_embedding vector(1536),
filter_metadata JSONB,
match_count INT DEFAULT 10
) RETURNS TABLE (id UUID, content TEXT, similarity FLOAT) AS $$
BEGIN
RETURN QUERY SELECT d.id, d.content,
1 - (d.embedding <=> query_embedding) AS similarity
FROM documents d
WHERE d.metadata @> filter_metadata
ORDER BY d.embedding <=> query_embedding
LIMIT match_count;
END; $$;
```
### Usage with Filters
```sql
SELECT * FROM search_with_filters(
'[0.1, 0.2, ...]'::vector(1536),
'{"category": "technical", "language": "en"}'::jsonb,
10
);
```
## Context7 Query Examples
For latest pgvector documentation:
Topic: "pgvector extension indexes hnsw ivfflat"
Topic: "vector similarity search operators"
Topic: "postgresql full-text search tsvector"
---
Related Modules:
- row-level-security.md - Secure vector data access
- typescript-patterns.md - Client-side search implementation
@@ -0,0 +1,354 @@
---
name: realtime-presence
description: Real-time subscriptions and presence tracking for collaborative features
parent-skill: moai-platform-supabase
version: 1.0.0
updated: 2026-01-06
---
# Real-time and Presence Module
## Overview
Supabase provides real-time capabilities through Postgres Changes (database change notifications) and Presence (user state tracking) for building collaborative applications.
## Postgres Changes Subscription
### Basic Setup
Subscribe to all changes on a table:
```typescript
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY)
const channel = supabase.channel('db-changes')
.on('postgres_changes',
{ event: '*', schema: 'public', table: 'messages' },
(payload) => console.log('Change:', payload)
)
.subscribe()
```
### Event Types
Available events:
- `INSERT` - New row added
- `UPDATE` - Row modified
- `DELETE` - Row removed
- `*` - All events
### Filtered Subscriptions
Filter changes by specific conditions:
```typescript
supabase.channel('project-updates')
.on('postgres_changes',
{
event: 'UPDATE',
schema: 'public',
table: 'projects',
filter: `id=eq.${projectId}`
},
(payload) => handleProjectUpdate(payload.new)
)
.subscribe()
```
### Multiple Tables
Subscribe to multiple tables on one channel:
```typescript
const channel = supabase.channel('app-changes')
.on('postgres_changes',
{ event: '*', schema: 'public', table: 'tasks' },
handleTaskChange
)
.on('postgres_changes',
{ event: '*', schema: 'public', table: 'comments' },
handleCommentChange
)
.subscribe()
```
## Presence Tracking
### Presence State Interface
```typescript
interface PresenceState {
user_id: string
online_at: string
typing?: boolean
cursor?: { x: number; y: number }
}
```
### Channel Setup with Presence
```typescript
const channel = supabase.channel('room:collaborative-doc', {
config: { presence: { key: userId } }
})
channel
.on('presence', { event: 'sync' }, () => {
const state = channel.presenceState<PresenceState>()
console.log('Online users:', Object.keys(state))
})
.on('presence', { event: 'join' }, ({ key, newPresences }) => {
console.log('User joined:', key, newPresences)
})
.on('presence', { event: 'leave' }, ({ key, leftPresences }) => {
console.log('User left:', key, leftPresences)
})
.subscribe(async (status) => {
if (status === 'SUBSCRIBED') {
await channel.track({
user_id: userId,
online_at: new Date().toISOString()
})
}
})
```
### Update Presence State
Update user presence in real-time:
```typescript
// Track typing status
await channel.track({ typing: true })
// Track cursor position
await channel.track({ cursor: { x: 100, y: 200 } })
// Clear typing after timeout
setTimeout(async () => {
await channel.track({ typing: false })
}, 1000)
```
## Collaborative Features
### Collaborative Cursors
```typescript
interface CursorState {
user_id: string
user_name: string
cursor: { x: number; y: number }
color: string
}
function setupCollaborativeCursors(documentId: string, userId: string, userName: string) {
const channel = supabase.channel(`cursors:${documentId}`, {
config: { presence: { key: userId } }
})
const colors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7']
const userColor = colors[Math.abs(userId.hashCode()) % colors.length]
channel
.on('presence', { event: 'sync' }, () => {
const state = channel.presenceState<CursorState>()
renderCursors(Object.values(state).flat())
})
.subscribe(async (status) => {
if (status === 'SUBSCRIBED') {
await channel.track({
user_id: userId,
user_name: userName,
cursor: { x: 0, y: 0 },
color: userColor
})
}
})
// Track mouse movement
document.addEventListener('mousemove', async (e) => {
await channel.track({
user_id: userId,
user_name: userName,
cursor: { x: e.clientX, y: e.clientY },
color: userColor
})
})
return channel
}
```
### Live Editing Indicators
```typescript
interface EditingState {
user_id: string
user_name: string
editing_field: string | null
}
function setupFieldLocking(formId: string) {
const channel = supabase.channel(`form:${formId}`, {
config: { presence: { key: currentUserId } }
})
channel
.on('presence', { event: 'sync' }, () => {
const state = channel.presenceState<EditingState>()
updateFieldLocks(Object.values(state).flat())
})
.subscribe()
return {
startEditing: async (fieldName: string) => {
await channel.track({
user_id: currentUserId,
user_name: currentUserName,
editing_field: fieldName
})
},
stopEditing: async () => {
await channel.track({
user_id: currentUserId,
user_name: currentUserName,
editing_field: null
})
}
}
}
```
## Broadcast Messages
Send arbitrary messages to channel subscribers:
```typescript
const channel = supabase.channel('room:chat')
// Subscribe to broadcasts
channel
.on('broadcast', { event: 'message' }, ({ payload }) => {
console.log('Received:', payload)
})
.subscribe()
// Send broadcast
await channel.send({
type: 'broadcast',
event: 'message',
payload: { text: 'Hello everyone!', sender: userId }
})
```
## Subscription Management
### Unsubscribe
```typescript
// Unsubscribe from specific channel
await supabase.removeChannel(channel)
// Unsubscribe from all channels
await supabase.removeAllChannels()
```
### Subscription Status
```typescript
channel.subscribe((status) => {
switch (status) {
case 'SUBSCRIBED':
console.log('Connected to channel')
break
case 'CLOSED':
console.log('Channel closed')
break
case 'CHANNEL_ERROR':
console.log('Channel error')
break
case 'TIMED_OUT':
console.log('Connection timed out')
break
}
})
```
## React Integration
### Custom Hook for Presence
```typescript
import { useEffect, useState } from 'react'
import { supabase } from './supabase/client'
export function usePresence<T>(channelName: string, userId: string, initialState: T) {
const [presences, setPresences] = useState<Record<string, T[]>>({})
useEffect(() => {
const channel = supabase.channel(channelName, {
config: { presence: { key: userId } }
})
channel
.on('presence', { event: 'sync' }, () => {
setPresences(channel.presenceState<T>())
})
.subscribe(async (status) => {
if (status === 'SUBSCRIBED') {
await channel.track(initialState)
}
})
return () => {
supabase.removeChannel(channel)
}
}, [channelName, userId])
const updatePresence = async (state: Partial<T>) => {
const channel = supabase.getChannels().find(c => c.topic === channelName)
if (channel) {
await channel.track({ ...initialState, ...state } as T)
}
}
return { presences, updatePresence }
}
```
### Usage
```typescript
function CollaborativeEditor({ documentId, userId }) {
const { presences, updatePresence } = usePresence(
`doc:${documentId}`,
userId,
{ user_id: userId, typing: false, cursor: null }
)
return (
<div>
{Object.values(presences).flat().map(p => (
<Cursor key={p.user_id} position={p.cursor} />
))}
</div>
)
}
```
## Context7 Query Examples
For latest real-time documentation:
Topic: "realtime postgres_changes subscription"
Topic: "presence tracking channel"
Topic: "broadcast messages supabase"
---
Related Modules:
- typescript-patterns.md - Client architecture
- auth-integration.md - Authenticated subscriptions
@@ -0,0 +1,286 @@
---
name: row-level-security
description: RLS policies for multi-tenant data isolation and access control
parent-skill: moai-platform-supabase
version: 1.0.0
updated: 2026-01-06
---
# Row-Level Security (RLS) Module
## Overview
Row-Level Security provides automatic data isolation at the database level, ensuring users can only access data they are authorized to see.
## Basic Setup
Enable RLS on a table:
```sql
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
```
## Policy Types
RLS policies can be created for specific operations:
- SELECT: Controls read access
- INSERT: Controls creation
- UPDATE: Controls modification
- DELETE: Controls removal
- ALL: Applies to all operations
## Basic Tenant Isolation
### JWT-Based Tenant Isolation
Extract tenant ID from JWT claims:
```sql
CREATE POLICY "tenant_isolation" ON projects FOR ALL
USING (tenant_id = (auth.jwt() ->> 'tenant_id')::UUID);
```
### Owner-Based Access
Restrict access to resource owners:
```sql
CREATE POLICY "owner_access" ON projects FOR ALL
USING (owner_id = auth.uid());
```
## Hierarchical Access Patterns
### Organization Membership
Allow access based on organization membership:
```sql
CREATE POLICY "org_member_select" ON organizations FOR SELECT
USING (id IN (SELECT org_id FROM org_members WHERE user_id = auth.uid()));
```
### Role-Based Modification
Restrict modifications to specific roles:
```sql
CREATE POLICY "org_admin_modify" ON organizations FOR UPDATE
USING (id IN (
SELECT org_id FROM org_members
WHERE user_id = auth.uid() AND role IN ('owner', 'admin')
));
```
### Cascading Project Access
Grant project access through organization membership:
```sql
CREATE POLICY "project_access" ON projects FOR ALL
USING (org_id IN (SELECT org_id FROM org_members WHERE user_id = auth.uid()));
```
## Service Role Bypass
Allow service role to bypass RLS for server-side operations:
```sql
CREATE POLICY "service_bypass" ON organizations FOR ALL TO service_role USING (true);
```
## Multi-Tenant SaaS Schema
### Complete Schema Setup
```sql
-- Organizations (tenants)
CREATE TABLE organizations (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL,
plan TEXT DEFAULT 'free' CHECK (plan IN ('free', 'pro', 'enterprise')),
settings JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Organization members with roles
CREATE TABLE organization_members (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
organization_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
user_id UUID NOT NULL,
role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'member', 'viewer')),
joined_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(organization_id, user_id)
);
-- Projects within organizations
CREATE TABLE projects (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
organization_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
name TEXT NOT NULL,
owner_id UUID NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
```
### Enable RLS on All Tables
```sql
ALTER TABLE organizations ENABLE ROW LEVEL SECURITY;
ALTER TABLE organization_members ENABLE ROW LEVEL SECURITY;
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
```
### Comprehensive RLS Policies
```sql
-- Organization read access
CREATE POLICY "org_member_select" ON organizations FOR SELECT
USING (id IN (SELECT organization_id FROM organization_members WHERE user_id = auth.uid()));
-- Organization admin update
CREATE POLICY "org_admin_update" ON organizations FOR UPDATE
USING (id IN (SELECT organization_id FROM organization_members
WHERE user_id = auth.uid() AND role IN ('owner', 'admin')));
-- Project member access
CREATE POLICY "project_member_access" ON projects FOR ALL
USING (organization_id IN (SELECT organization_id FROM organization_members WHERE user_id = auth.uid()));
-- Member management (admin only)
CREATE POLICY "member_admin_manage" ON organization_members FOR ALL
USING (organization_id IN (SELECT organization_id FROM organization_members
WHERE user_id = auth.uid() AND role IN ('owner', 'admin')));
```
## Helper Functions
### Check Organization Membership
```sql
CREATE OR REPLACE FUNCTION is_org_member(org_id UUID)
RETURNS BOOLEAN AS $$
BEGIN
RETURN EXISTS (
SELECT 1 FROM organization_members
WHERE organization_id = org_id AND user_id = auth.uid()
);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
```
### Check Organization Role
```sql
CREATE OR REPLACE FUNCTION has_org_role(org_id UUID, required_roles TEXT[])
RETURNS BOOLEAN AS $$
BEGIN
RETURN EXISTS (
SELECT 1 FROM organization_members
WHERE organization_id = org_id
AND user_id = auth.uid()
AND role = ANY(required_roles)
);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
```
### Usage in Policies
```sql
CREATE POLICY "project_admin_delete" ON projects FOR DELETE
USING (has_org_role(organization_id, ARRAY['owner', 'admin']));
```
## Performance Optimization
### Index for RLS Queries
Create indexes on foreign keys used in RLS policies:
```sql
CREATE INDEX idx_org_members_user ON organization_members(user_id);
CREATE INDEX idx_org_members_org ON organization_members(organization_id);
CREATE INDEX idx_projects_org ON projects(organization_id);
```
### Materialized View for Complex Policies
For complex permission checks, use materialized views:
```sql
CREATE MATERIALIZED VIEW user_accessible_projects AS
SELECT p.id as project_id, om.user_id, om.role
FROM projects p
JOIN organization_members om ON p.organization_id = om.organization_id;
CREATE INDEX idx_uap_user ON user_accessible_projects(user_id);
REFRESH MATERIALIZED VIEW CONCURRENTLY user_accessible_projects;
```
## Testing RLS Policies
### Test as Authenticated User
```sql
SET request.jwt.claim.sub = 'user-uuid-here';
SET request.jwt.claims = '{"role": "authenticated"}';
SELECT * FROM projects; -- Returns only accessible projects
```
### Verify Policy Restrictions
```sql
-- Should fail if not a member
INSERT INTO projects (organization_id, name, owner_id)
VALUES ('non-member-org-id', 'Test', auth.uid());
```
## Common Patterns
### Public Read, Owner Write
```sql
CREATE POLICY "public_read" ON posts FOR SELECT USING (true);
CREATE POLICY "owner_write" ON posts FOR INSERT WITH CHECK (author_id = auth.uid());
CREATE POLICY "owner_update" ON posts FOR UPDATE USING (author_id = auth.uid());
CREATE POLICY "owner_delete" ON posts FOR DELETE USING (author_id = auth.uid());
```
### Draft vs Published
```sql
CREATE POLICY "published_read" ON articles FOR SELECT
USING (status = 'published' OR author_id = auth.uid());
```
### Time-Based Access
```sql
CREATE POLICY "active_subscription" ON premium_content FOR SELECT
USING (
EXISTS (
SELECT 1 FROM subscriptions
WHERE user_id = auth.uid()
AND expires_at > NOW()
)
);
```
## Context7 Query Examples
For latest RLS documentation:
Topic: "row level security policies supabase"
Topic: "auth.uid auth.jwt functions"
Topic: "rls performance optimization"
---
Related Modules:
- auth-integration.md - Authentication patterns
- typescript-patterns.md - Client-side access patterns
@@ -0,0 +1,319 @@
---
name: storage-cdn
description: File storage with image transformations and CDN delivery
parent-skill: moai-platform-supabase
version: 1.0.0
updated: 2026-01-06
---
# Storage and CDN Module
## Overview
Supabase Storage provides file storage with automatic image transformations, CDN delivery, and fine-grained access control through storage policies.
## Basic Upload
### Upload File
```typescript
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY)
async function uploadFile(file: File, bucket: string, path: string) {
const { data, error } = await supabase.storage
.from(bucket)
.upload(path, file, {
cacheControl: '3600',
upsert: false
})
if (error) throw error
return data.path
}
```
### Upload with User Context
```typescript
async function uploadUserFile(file: File, userId: string) {
const fileName = `${userId}/${Date.now()}-${file.name}`
const { data, error } = await supabase.storage
.from('user-files')
.upload(fileName, file, {
cacheControl: '3600',
upsert: false
})
if (error) throw error
return data
}
```
## Image Transformations
### Get Transformed URL
```typescript
async function uploadImage(file: File, userId: string) {
const fileName = `${userId}/${Date.now()}-${file.name}`
const { data, error } = await supabase.storage
.from('images')
.upload(fileName, file, { cacheControl: '3600', upsert: false })
if (error) throw error
// Get original URL
const { data: { publicUrl } } = supabase.storage
.from('images')
.getPublicUrl(fileName)
// Get resized URL
const { data: { publicUrl: resizedUrl } } = supabase.storage
.from('images')
.getPublicUrl(fileName, {
transform: { width: 800, height: 600, resize: 'contain' }
})
// Get thumbnail URL
const { data: { publicUrl: thumbnailUrl } } = supabase.storage
.from('images')
.getPublicUrl(fileName, {
transform: { width: 200, height: 200, resize: 'cover' }
})
return { originalPath: data.path, publicUrl, resizedUrl, thumbnailUrl }
}
```
### Transform Options
Available transformation parameters:
- width: Target width in pixels
- height: Target height in pixels
- resize: 'cover' | 'contain' | 'fill'
- format: 'origin' | 'avif' | 'webp'
- quality: 1-100
### Example Transforms
```typescript
// Square thumbnail with crop
const thumbnail = supabase.storage
.from('images')
.getPublicUrl(path, {
transform: { width: 150, height: 150, resize: 'cover' }
})
// WebP format for smaller size
const webp = supabase.storage
.from('images')
.getPublicUrl(path, {
transform: { width: 800, format: 'webp', quality: 80 }
})
// Responsive image
const responsive = supabase.storage
.from('images')
.getPublicUrl(path, {
transform: { width: 400, resize: 'contain' }
})
```
## Bucket Management
### Create Bucket
```sql
-- Via SQL
INSERT INTO storage.buckets (id, name, public)
VALUES ('images', 'images', true);
```
### Bucket Policies
```sql
-- Allow authenticated users to upload to their folder
CREATE POLICY "User upload" ON storage.objects
FOR INSERT
TO authenticated
WITH CHECK (bucket_id = 'user-files' AND (storage.foldername(name))[1] = auth.uid()::text);
-- Allow public read on images bucket
CREATE POLICY "Public read" ON storage.objects
FOR SELECT
TO public
USING (bucket_id = 'images');
-- Allow users to delete their own files
CREATE POLICY "User delete" ON storage.objects
FOR DELETE
TO authenticated
USING (bucket_id = 'user-files' AND (storage.foldername(name))[1] = auth.uid()::text);
```
## Download Files
### Get Signed URL
For private buckets:
```typescript
async function getSignedUrl(bucket: string, path: string, expiresIn: number = 3600) {
const { data, error } = await supabase.storage
.from(bucket)
.createSignedUrl(path, expiresIn)
if (error) throw error
return data.signedUrl
}
```
### Download File
```typescript
async function downloadFile(bucket: string, path: string) {
const { data, error } = await supabase.storage
.from(bucket)
.download(path)
if (error) throw error
return data // Blob
}
```
## File Management
### List Files
```typescript
async function listFiles(bucket: string, folder: string) {
const { data, error } = await supabase.storage
.from(bucket)
.list(folder, {
limit: 100,
offset: 0,
sortBy: { column: 'created_at', order: 'desc' }
})
if (error) throw error
return data
}
```
### Delete File
```typescript
async function deleteFile(bucket: string, paths: string[]) {
const { data, error } = await supabase.storage
.from(bucket)
.remove(paths)
if (error) throw error
return data
}
```
### Move/Rename File
```typescript
async function moveFile(bucket: string, fromPath: string, toPath: string) {
const { data, error } = await supabase.storage
.from(bucket)
.move(fromPath, toPath)
if (error) throw error
return data
}
```
## React Integration
### Upload Component
```typescript
function FileUploader({ bucket, onUpload }: Props) {
const [uploading, setUploading] = useState(false)
async function handleUpload(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0]
if (!file) return
setUploading(true)
try {
const path = `${Date.now()}-${file.name}`
const { error } = await supabase.storage
.from(bucket)
.upload(path, file)
if (error) throw error
onUpload(path)
} finally {
setUploading(false)
}
}
return (
<input
type="file"
onChange={handleUpload}
disabled={uploading}
/>
)
}
```
### Image with Fallback
```typescript
function StorageImage({ path, bucket, width, height, fallback }: Props) {
const { data: { publicUrl } } = supabase.storage
.from(bucket)
.getPublicUrl(path, {
transform: { width, height, resize: 'cover' }
})
return (
<img
src={publicUrl}
alt=""
onError={(e) => { e.currentTarget.src = fallback }}
width={width}
height={height}
/>
)
}
```
## Best Practices
File Organization:
- Use user ID as folder prefix for user content
- Include timestamp in filenames to prevent collisions
- Use consistent naming conventions
Performance:
- Set appropriate cache-control headers
- Use image transformations instead of storing multiple sizes
- Leverage CDN for global delivery
Security:
- Always use RLS-style policies for storage
- Use signed URLs for private content
- Validate file types before upload
## Context7 Query Examples
For latest Storage documentation:
Topic: "supabase storage upload download"
Topic: "storage image transformations"
Topic: "storage bucket policies"
---
Related Modules:
- row-level-security.md - Storage access policies
- typescript-patterns.md - Client patterns
@@ -0,0 +1,453 @@
---
name: typescript-patterns
description: TypeScript client patterns and service layer architecture for Supabase
parent-skill: moai-platform-supabase
version: 1.0.0
updated: 2026-01-06
---
# TypeScript Patterns Module
## Overview
Type-safe Supabase client patterns for building maintainable full-stack applications with TypeScript.
## Type Generation
### Generate Types from Database
```bash
supabase gen types typescript --project-id your-project-id > database.types.ts
```
### Database Types Structure
```typescript
// database.types.ts
export type Database = {
public: {
Tables: {
projects: {
Row: {
id: string
name: string
organization_id: string
owner_id: string
created_at: string
}
Insert: {
id?: string
name: string
organization_id: string
owner_id: string
created_at?: string
}
Update: {
id?: string
name?: string
organization_id?: string
owner_id?: string
created_at?: string
}
}
// ... other tables
}
Functions: {
search_documents: {
Args: {
query_embedding: number[]
match_threshold: number
match_count: number
}
Returns: { id: string; content: string; similarity: number }[]
}
}
}
}
```
## Client Configuration
### Browser Client with Types
```typescript
import { createClient } from '@supabase/supabase-js'
import { Database } from './database.types'
export const supabase = createClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
```
### Server Client (Next.js App Router)
```typescript
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
import { Database } from './database.types'
export function createServerSupabase() {
const cookieStore = cookies()
return createServerClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
get(name: string) {
return cookieStore.get(name)?.value
},
set(name, value, options) {
cookieStore.set({ name, value, ...options })
},
remove(name, options) {
cookieStore.set({ name, value: '', ...options })
}
}
}
)
}
```
## Service Layer Pattern
### Base Service
```typescript
import { supabase } from './supabase/client'
import { Database } from './database.types'
type Tables = Database['public']['Tables']
export abstract class BaseService<T extends keyof Tables> {
constructor(protected tableName: T) {}
async findAll() {
const { data, error } = await supabase
.from(this.tableName)
.select('*')
if (error) throw error
return data as Tables[T]['Row'][]
}
async findById(id: string) {
const { data, error } = await supabase
.from(this.tableName)
.select('*')
.eq('id', id)
.single()
if (error) throw error
return data as Tables[T]['Row']
}
async create(item: Tables[T]['Insert']) {
const { data, error } = await supabase
.from(this.tableName)
.insert(item)
.select()
.single()
if (error) throw error
return data as Tables[T]['Row']
}
async update(id: string, item: Tables[T]['Update']) {
const { data, error } = await supabase
.from(this.tableName)
.update(item)
.eq('id', id)
.select()
.single()
if (error) throw error
return data as Tables[T]['Row']
}
async delete(id: string) {
const { error } = await supabase
.from(this.tableName)
.delete()
.eq('id', id)
if (error) throw error
}
}
```
### Document Service with Embeddings
```typescript
import { supabase } from './supabase/client'
export class DocumentService {
async create(projectId: string, title: string, content: string) {
const { data: { user } } = await supabase.auth.getUser()
const { data, error } = await supabase
.from('documents')
.insert({
project_id: projectId,
title,
content,
created_by: user!.id
})
.select()
.single()
if (error) throw error
// Generate embedding asynchronously
await supabase.functions.invoke('generate-embedding', {
body: { documentId: data.id, content }
})
return data
}
async semanticSearch(projectId: string, query: string) {
// Get embedding for query
const { data: embeddingData } = await supabase.functions.invoke(
'get-embedding',
{ body: { text: query } }
)
// Search using RPC
const { data, error } = await supabase.rpc('search_documents', {
p_project_id: projectId,
p_query_embedding: embeddingData.embedding,
p_match_threshold: 0.7,
p_match_count: 10
})
if (error) throw error
return data
}
async findByProject(projectId: string) {
const { data, error } = await supabase
.from('documents')
.select('*, created_by_user:profiles!created_by(*)')
.eq('project_id', projectId)
.order('created_at', { ascending: false })
if (error) throw error
return data
}
subscribeToChanges(projectId: string, callback: (payload: any) => void) {
return supabase.channel(`documents:${projectId}`)
.on('postgres_changes',
{
event: '*',
schema: 'public',
table: 'documents',
filter: `project_id=eq.${projectId}`
},
callback
)
.subscribe()
}
}
export const documentService = new DocumentService()
```
## React Query Integration
### Query Keys
```typescript
export const queryKeys = {
projects: {
all: ['projects'] as const,
list: (filters?: ProjectFilters) => [...queryKeys.projects.all, 'list', filters] as const,
detail: (id: string) => [...queryKeys.projects.all, 'detail', id] as const
},
documents: {
all: ['documents'] as const,
list: (projectId: string) => [...queryKeys.documents.all, 'list', projectId] as const,
search: (projectId: string, query: string) =>
[...queryKeys.documents.all, 'search', projectId, query] as const
}
}
```
### Custom Hooks
```typescript
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { documentService } from '@/services/document-service'
export function useDocuments(projectId: string) {
return useQuery({
queryKey: queryKeys.documents.list(projectId),
queryFn: () => documentService.findByProject(projectId)
})
}
export function useSemanticSearch(projectId: string, query: string) {
return useQuery({
queryKey: queryKeys.documents.search(projectId, query),
queryFn: () => documentService.semanticSearch(projectId, query),
enabled: query.length > 2
})
}
export function useCreateDocument(projectId: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ title, content }: { title: string; content: string }) =>
documentService.create(projectId, title, content),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: queryKeys.documents.list(projectId)
})
}
})
}
```
## Real-time with React
### Subscription Hook
```typescript
import { useEffect } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { supabase } from '@/lib/supabase/client'
export function useRealtimeDocuments(projectId: string) {
const queryClient = useQueryClient()
useEffect(() => {
const channel = supabase
.channel(`documents:${projectId}`)
.on('postgres_changes',
{
event: '*',
schema: 'public',
table: 'documents',
filter: `project_id=eq.${projectId}`
},
() => {
queryClient.invalidateQueries({
queryKey: queryKeys.documents.list(projectId)
})
}
)
.subscribe()
return () => {
supabase.removeChannel(channel)
}
}, [projectId, queryClient])
}
```
### Optimistic Updates
```typescript
export function useUpdateDocument() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ id, updates }: { id: string; updates: Partial<Document> }) => {
const { data, error } = await supabase
.from('documents')
.update(updates)
.eq('id', id)
.select()
.single()
if (error) throw error
return data
},
onMutate: async ({ id, updates }) => {
await queryClient.cancelQueries({ queryKey: ['documents'] })
const previousDocuments = queryClient.getQueryData(['documents'])
queryClient.setQueryData(['documents'], (old: Document[]) =>
old.map(doc => doc.id === id ? { ...doc, ...updates } : doc)
)
return { previousDocuments }
},
onError: (err, variables, context) => {
if (context?.previousDocuments) {
queryClient.setQueryData(['documents'], context.previousDocuments)
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['documents'] })
}
})
}
```
## Error Handling
### Custom Error Types
```typescript
export class SupabaseError extends Error {
constructor(
message: string,
public code: string,
public details?: unknown
) {
super(message)
this.name = 'SupabaseError'
}
}
export function handleSupabaseError(error: PostgrestError): never {
switch (error.code) {
case '23505':
throw new SupabaseError('Resource already exists', 'DUPLICATE', error)
case '23503':
throw new SupabaseError('Referenced resource not found', 'NOT_FOUND', error)
case 'PGRST116':
throw new SupabaseError('Resource not found', 'NOT_FOUND', error)
default:
throw new SupabaseError(error.message, error.code, error)
}
}
```
### Service with Error Handling
```typescript
async findById(id: string) {
const { data, error } = await supabase
.from(this.tableName)
.select('*')
.eq('id', id)
.single()
if (error) {
handleSupabaseError(error)
}
return data
}
```
## Context7 Query Examples
For latest client documentation:
Topic: "supabase-js typescript client"
Topic: "supabase ssr next.js app router"
Topic: "supabase realtime subscription"
---
Related Modules:
- auth-integration.md - Auth patterns
- realtime-presence.md - Real-time subscriptions
- postgresql-pgvector.md - Database operations
@@ -0,0 +1,284 @@
---
name: supabase-reference
description: API reference summary for Supabase platform
parent-skill: moai-platform-supabase
version: 1.0.0
updated: 2026-01-06
---
# Supabase API Reference
## Client Methods
### Database Operations
```typescript
// Select
const { data, error } = await supabase.from('table').select('*')
const { data } = await supabase.from('table').select('id, name, relation(*)').eq('id', 1)
// Insert
const { data, error } = await supabase.from('table').insert({ column: 'value' }).select()
const { data } = await supabase.from('table').insert([...items]).select()
// Update
const { data, error } = await supabase.from('table').update({ column: 'value' }).eq('id', 1).select()
// Upsert
const { data, error } = await supabase.from('table').upsert({ id: 1, column: 'value' }).select()
// Delete
const { error } = await supabase.from('table').delete().eq('id', 1)
```
### Query Filters
```typescript
.eq('column', 'value') // Equal
.neq('column', 'value') // Not equal
.gt('column', 0) // Greater than
.gte('column', 0) // Greater than or equal
.lt('column', 100) // Less than
.lte('column', 100) // Less than or equal
.like('column', '%pattern%') // LIKE
.ilike('column', '%pattern%') // ILIKE (case insensitive)
.is('column', null) // IS NULL
.in('column', ['a', 'b']) // IN
.contains('array_col', ['a']) // Array contains
.containedBy('col', ['a','b']) // Array contained by
.range('col', '[1,10)') // Range
.textSearch('col', 'query') // Full-text search
.filter('col', 'op', 'val') // Generic filter
```
### Query Modifiers
```typescript
.order('column', { ascending: false })
.limit(10)
.range(0, 9) // Pagination
.single() // Expect exactly one row
.maybeSingle() // Expect zero or one row
.count('exact', { head: true }) // Count only
```
### RPC (Remote Procedure Call)
```typescript
const { data, error } = await supabase.rpc('function_name', {
arg1: 'value1',
arg2: 'value2'
})
```
## Auth Methods
```typescript
// Sign up
await supabase.auth.signUp({ email, password })
// Sign in
await supabase.auth.signInWithPassword({ email, password })
await supabase.auth.signInWithOAuth({ provider: 'google' })
await supabase.auth.signInWithOtp({ email })
// Session
await supabase.auth.getUser()
await supabase.auth.getSession()
await supabase.auth.refreshSession()
// Sign out
await supabase.auth.signOut()
// Password
await supabase.auth.resetPasswordForEmail(email)
await supabase.auth.updateUser({ password: newPassword })
// Listener
supabase.auth.onAuthStateChange((event, session) => {})
```
## Real-time Methods
```typescript
// Subscribe to changes
const channel = supabase.channel('channel-name')
.on('postgres_changes',
{ event: '*', schema: 'public', table: 'table_name' },
(payload) => {}
)
.subscribe()
// Presence
channel.on('presence', { event: 'sync' }, () => {
const state = channel.presenceState()
})
await channel.track({ user_id: 'id', online_at: new Date() })
// Broadcast
await channel.send({ type: 'broadcast', event: 'name', payload: {} })
channel.on('broadcast', { event: 'name' }, ({ payload }) => {})
// Unsubscribe
await supabase.removeChannel(channel)
await supabase.removeAllChannels()
```
## Storage Methods
```typescript
// Upload
await supabase.storage.from('bucket').upload('path/file.ext', file, { cacheControl: '3600' })
// Download
await supabase.storage.from('bucket').download('path/file.ext')
// Get URL
supabase.storage.from('bucket').getPublicUrl('path/file.ext', {
transform: { width: 800, height: 600, resize: 'cover' }
})
// Signed URL
await supabase.storage.from('bucket').createSignedUrl('path/file.ext', 3600)
// List
await supabase.storage.from('bucket').list('folder', { limit: 100 })
// Delete
await supabase.storage.from('bucket').remove(['path/file.ext'])
// Move
await supabase.storage.from('bucket').move('old/path', 'new/path')
```
## Edge Functions
```typescript
// Invoke
const { data, error } = await supabase.functions.invoke('function-name', {
body: { key: 'value' },
headers: { 'Custom-Header': 'value' }
})
```
## SQL Quick Reference
### pgvector
```sql
CREATE EXTENSION vector;
-- Create table with vector column
CREATE TABLE items (
id UUID PRIMARY KEY,
embedding vector(1536)
);
-- HNSW index (recommended)
CREATE INDEX ON items USING hnsw (embedding vector_cosine_ops);
-- IVFFlat index (large datasets)
CREATE INDEX ON items USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);
-- Distance operators
<-> -- Euclidean distance
<=> -- Cosine distance
<#> -- Negative inner product
```
### Row-Level Security
```sql
-- Enable RLS
ALTER TABLE table_name ENABLE ROW LEVEL SECURITY;
-- Create policy
CREATE POLICY "policy_name" ON table_name
FOR SELECT | INSERT | UPDATE | DELETE | ALL
TO role_name
USING (expression)
WITH CHECK (expression);
-- Auth functions
auth.uid() -- Current user ID
auth.jwt() ->> 'claim' -- JWT claim value
auth.role() -- Current role
```
### Useful Functions
```sql
gen_random_uuid() -- Generate UUID
uuid_generate_v4() -- Generate UUID (requires uuid-ossp)
NOW() -- Current timestamp
CURRENT_TIMESTAMP -- Current timestamp
to_tsvector('english', text) -- Full-text search vector
plainto_tsquery('query') -- Full-text search query
ts_rank(vector, query) -- Full-text search rank
```
## Environment Variables
```bash
# Public (safe for client)
NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
# Private (server-only)
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
SUPABASE_JWT_SECRET=your-jwt-secret
```
## CLI Commands
```bash
# Project
supabase init
supabase start
supabase stop
supabase status
# Database
supabase db diff
supabase db push
supabase db reset
supabase migration new migration_name
supabase migration list
# Types
supabase gen types typescript --project-id xxx > database.types.ts
# Functions
supabase functions new function-name
supabase functions serve function-name
supabase functions deploy function-name
supabase functions list
# Secrets
supabase secrets set KEY=value
supabase secrets list
```
## Context7 Documentation Access
For detailed API documentation, use Context7 MCP tools:
```
Step 1: Resolve library ID
mcp__context7__resolve-library-id with query "supabase"
Step 2: Fetch documentation
mcp__context7__get-library-docs with:
- context7CompatibleLibraryID: resolved ID
- topic: "specific topic"
- tokens: 5000-10000
```
Common topics:
- "javascript client select insert update"
- "auth signIn signUp oauth"
- "realtime postgres_changes presence"
- "storage upload download transform"
- "edge-functions deploy invoke"
- "row-level-security policies"
- "pgvector similarity search"
+3 -3
View File
@@ -1,6 +1,6 @@
# CLAUDE.md # COPILOT_README.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. This file provides guidance to Github Copilot when working with code in this repository.
## Development Commands ## Development Commands
@@ -15,7 +15,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
1. **Code**: Write feature with implementation in `lib/` and tests in `test/` 1. **Code**: Write feature with implementation in `lib/` and tests in `test/`
2. **Analyze**: `flutter analyze` (Must be clean) 2. **Analyze**: `flutter analyze` (Must be clean)
3. **Test**: `flutter test` (Must pass) 3. **Test**: `flutter test` (Must pass without overflow or rendering exceptions)
4. **Verify**: Ensure UI matches the Hybrid M3/M2 guidelines 4. **Verify**: Ensure UI matches the Hybrid M3/M2 guidelines
## Architecture Overview ## Architecture Overview
+46
View File
@@ -0,0 +1,46 @@
# TasQ Release Notes
Version: 0.0.0.1
Date: 2026-02-22
## Overview
This release contains a working core of TasQ focused on task and ticket handling, user and office management, workforce features, and an initial implementation of geofencing. It is an early release intended for internal testing and iterative feature work.
## Working Features
- **Tickets**: Create, view, and manage tickets. Basic assignment and status updates are functional with server-side querying and provider-driven UI flows.
- **Tasks**: Task creation, listing, and updates are implemented. Includes an IT job printout feature for exporting/printing IT job details and work instructions.
- **User Management**: Admins can create and manage user profiles and roles. Authentication flows and profile providers are in place to support role-based access.
- **Office Management**: Offices can be created and maintained; users and tasks may be scoped by office assignment for filtered views.
- **Workforce**: Workforce management features such as viewing and assigning staff are implemented, including basic shift/duty assignment tooling used by the app's workforce screens.
- **Initial Geofencing**: A first-pass geofencing implementation is available (admin creation of geofences and basic detection hooks). This is an initial implementation intended for iteration — see roadmap for full rollout.
## Known Limitations
- Geofencing is an initial implementation and lacks advanced triggers, notifications, and full device offline handling.
- Duty scheduling is functional but will be improved for more complex shift patterns and repeats.
- Reports, events, and announcements are not yet implemented.
## Roadmap (Planned Features)
- **Events**: Event creation, RSVP/attendance tracking, and calendar integrations.
- **Announcements & Reports**: Admin announcement bulletin and a reports suite (exportable CSV/PDF) for tickets, tasks, and workforce metrics.
- **Improved Duty Schedules**: Advanced scheduling, repeating patterns, and approval workflows for duty swaps and long-running schedules.
- **Geofencing (Full Rollout)**: Robust geofence lifecycle, realtime triggers/notifications, user/device-level geofence state, and integration with duties and timekeeping.
- **Offline Support**: Local caching, queued mutations, and background sync to support intermittent connectivity for mobile users.
## Tests and Quality
There are existing unit and widget tests under `test/` that cover core pieces of the app (providers, time utilities, some screens). Before wide release, ensure `flutter analyze` and `flutter test` are clean.
## Developer Notes
- Follow the repository's COPILOT_README.md / CLAUDE.md guidance for theming, server-side pagination, and provider patterns.
- Key folders: `lib/providers/`, `lib/routing/`, `lib/screens/`, `test/`.
## Next Steps
- Stabilize geofencing triggers and notification paths.
- Implement reports and announcements with RBAC checks.
- Add offline sync, prioritize duty schedule improvements, and add event/calendar support.
+31 -2
View File
@@ -1,5 +1,8 @@
plugins { plugins {
id("com.android.application") id("com.android.application")
// START: FlutterFire Configuration
id("com.google.gms.google-services")
// END: FlutterFire Configuration
id("kotlin-android") id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin") id("dev.flutter.flutter-gradle-plugin")
@@ -7,12 +10,19 @@ plugins {
android { android {
namespace = "com.example.tasq" namespace = "com.example.tasq"
compileSdk = flutter.compileSdkVersion // ensure we compile against a recent Android SDK (34+ required by some plugins)
// Flutter's generated `flutter.compileSdkVersion` may lag behind, so hardcode
// the value here to avoid resource/linking errors during release builds.
// many plugins now request API 36; we target the highest needed level.
compileSdk = 36
ndkVersion = flutter.ndkVersion ndkVersion = flutter.ndkVersion
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_17 sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17
// required by flutter_local_notifications and other libraries using
// Java 8+ APIs at runtime
isCoreLibraryDesugaringEnabled = true
} }
kotlinOptions { kotlinOptions {
@@ -25,7 +35,9 @@ android {
// You can update the following values to match your application needs. // You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config. // For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion // lock the target SDK to match compileSdk so that plugin requirements
// are satisfied. Higher targetSdk is backwards compatible.
targetSdk = 36
versionCode = flutter.versionCode versionCode = flutter.versionCode
versionName = flutter.versionName versionName = flutter.versionName
} }
@@ -37,6 +49,23 @@ android {
signingConfig = signingConfigs.getByName("debug") signingConfig = signingConfigs.getByName("debug")
} }
} }
// Disable lint checks during release builds to avoid intermittent
// file-lock errors on Windows (lintVitalAnalyzeRelease tasks can
// collide with other processes). The app is presumed stable and
// lint warnings are handled during development.
lint {
checkReleaseBuilds = false
abortOnError = false
}
// allow desugaring of Java 8+ library APIs
}
// dependencies section for additional compile-only libraries
dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
} }
flutter { flutter {
+29
View File
@@ -0,0 +1,29 @@
{
"project_info": {
"project_number": "173359574734",
"project_id": "tasq-17fb3",
"storage_bucket": "tasq-17fb3.firebasestorage.app"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:173359574734:android:28f9a6792ea579ad2baa9f",
"android_client_info": {
"package_name": "com.example.tasq"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyDt0ZYxfjXXxF4PS8NKbzOHFSHJC2LFvU4"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
}
],
"configuration_version": "1"
}
+42 -2
View File
@@ -1,10 +1,22 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
<!-- Required on Android 13+ to post notifications. Without this the system
will automatically block notifications and the user cannot enable them
from settings. -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application <application
android:label="tasq" android:label="tasq"
android:name="${applicationName}" android:name=".App"
android:icon="@mipmap/ic_launcher"> android:icon="@mipmap/ic_launcher">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
@@ -35,12 +47,40 @@
android:scheme="io.supabase.tasq" android:scheme="io.supabase.tasq"
android:host="login-callback" /> android:host="login-callback" />
</intent-filter> </intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="tasq"
android:host="verify"
android:pathPrefix="/" />
</intent-filter>
</activity> </activity>
<!-- Don't delete the meta-data below. <!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java --> This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data <meta-data
android:name="flutterEmbedding" android:name="flutterEmbedding"
android:value="2" /> android:value="2" />
<meta-data
android:name="com.google.firebase.messaging.default_notification_channel_id"
android:value="tasq_custom_sound_channel_3" />
<!-- Override the plugin's service to add foregroundServiceType (required Android 14+) -->
<service
android:name="id.flutter.flutter_background_service.BackgroundService"
android:foregroundServiceType="location"
tools:replace="android:foregroundServiceType" />
<!-- FileProvider for OTA plugin (ota_update) -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.ota_update_provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/ota_update_file_paths" />
</provider>
</application> </application>
<!-- Required to query activities that can process text, see: <!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and https://developer.android.com/training/package-visibility and
@@ -0,0 +1,38 @@
package com.example.tasq
import android.app.Application
import android.app.NotificationChannel
import android.app.NotificationManager
import android.media.AudioAttributes
import android.util.Log
import android.net.Uri
class App : Application() {
override fun onCreate() {
super.onCreate()
createTasqChannel()
}
private fun createTasqChannel() {
val channelId = "tasq_custom_sound_channel_3"
val name = "TasQ notifications"
val importance = NotificationManager.IMPORTANCE_HIGH
val channel = NotificationChannel(channelId, name, importance)
val soundUri = Uri.parse("android.resource://$packageName/raw/tasq_notification")
val audioAttributes = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_NOTIFICATION)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build()
channel.setSound(soundUri, audioAttributes)
// Ensure we do NOT bypass Do Not Disturb — let the system enforce silent/vibrate modes
channel.setBypassDnd(false)
// Allow vibration; system will respect device vibrate settings
channel.enableVibration(true)
val nm = getSystemService(NotificationManager::class.java)
nm?.createNotificationChannel(channel)
Log.d("App", "Created notification channel: $channelId with sound=$soundUri")
}
}
@@ -1,5 +1,49 @@
package com.example.tasq package com.example.tasq
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterFragmentActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import android.content.Intent
import android.net.Uri
import androidx.core.content.FileProvider
import java.io.File
class MainActivity : FlutterActivity() class MainActivity : FlutterFragmentActivity() {
private val CHANNEL = "tasq/ota"
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, _ ->
when (call.method) {
"openUnknownSources" -> {
try {
val intent = Intent(android.provider.Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES)
intent.data = Uri.parse("package:" + applicationContext.packageName)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
applicationContext.startActivity(intent)
} catch (e: Exception) {
// ignore and return
}
}
"installApk" -> {
try {
val path = call.argument<String>("path") ?: return@setMethodCallHandler
val file = File(path)
val authority = applicationContext.packageName + ".ota_update_provider"
val uri = FileProvider.getUriForFile(applicationContext, authority, file)
val intent = Intent(Intent.ACTION_VIEW)
intent.setDataAndType(uri, "application/vnd.android.package-archive")
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
applicationContext.startActivity(intent)
} catch (e: Exception) {
// ignore - caller will surface error
}
}
else -> {
// no-op
}
}
}
}
}
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<bitmap xmlns:android="http://schemas.android.com/apk/res/android"
android:src="@mipmap/ic_launcher"
android:gravity="center" />
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<bitmap xmlns:android="http://schemas.android.com/apk/res/android"
android:src="@mipmap/ic_launcher"
android:gravity="center" />
+3
View File
@@ -0,0 +1,3 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools"
tools:keep="@raw/tasq_notification" />
Binary file not shown.
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Allow access to common app storage locations used by OTA plugin -->
<external-path name="external_files" path="." />
<external-files-path name="external_files_app" path="." />
<cache-path name="cache" path="." />
<external-cache-path name="external_cache" path="." />
<files-path name="files" path="." />
</paths>
+68
View File
@@ -1,3 +1,9 @@
import com.android.build.gradle.LibraryExtension
import org.gradle.api.tasks.Delete
import com.android.build.gradle.BaseExtension
import org.gradle.api.JavaVersion
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
allprojects { allprojects {
repositories { repositories {
google() google()
@@ -15,10 +21,72 @@ subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir) project.layout.buildDirectory.value(newSubprojectBuildDir)
} }
// Force compileSdk, Java 17, and Kotlin JVM target 17 on all library subprojects.
// Must use the `subprojects {}` block (not `subprojects.forEach`) so the
// afterEvaluate callback is registered *before* evaluationDependsOn triggers.
subprojects {
afterEvaluate {
plugins.withId("com.android.library") {
val androidExt = extensions.findByName("android") as? BaseExtension
if (androidExt != null) {
try {
androidExt.compileOptions.sourceCompatibility = JavaVersion.VERSION_17
androidExt.compileOptions.targetCompatibility = JavaVersion.VERSION_17
} catch (_: Throwable) {}
try {
val sdkNum = androidExt.compileSdkVersion?.removePrefix("android-")?.toIntOrNull() ?: 0
if (sdkNum < 36) {
androidExt.compileSdkVersion("android-36")
}
} catch (_: Throwable) {}
}
// Align Kotlin JVM target with Java 17 to avoid mismatch errors
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
compilerOptions.jvmTarget.set(JvmTarget.JVM_17)
}
}
plugins.withId("com.android.application") {
val androidExt = extensions.findByName("android") as? BaseExtension
if (androidExt != null) {
try {
androidExt.compileOptions.sourceCompatibility = JavaVersion.VERSION_17
androidExt.compileOptions.targetCompatibility = JavaVersion.VERSION_17
} catch (_: Throwable) {}
}
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
compilerOptions.jvmTarget.set(JvmTarget.JVM_17)
}
}
}
}
subprojects { subprojects {
project.evaluationDependsOn(":app") project.evaluationDependsOn(":app")
} }
// Ensure older plugin modules that don't declare a `namespace` still build with AGP.
subprojects.forEach { project ->
project.plugins.withId("com.android.library") {
try {
val androidExt = project.extensions.findByName("android") as? LibraryExtension
if (androidExt != null) {
try {
val current = androidExt.namespace
if (current.isNullOrBlank()) {
androidExt.namespace = "com.tasq.${project.name.replace('-', '_')}"
}
} catch (e: Throwable) {
try {
androidExt.namespace = "com.tasq.${project.name.replace('-', '_')}"
} catch (_: Throwable) {}
}
}
} catch (_: Throwable) {}
}
}
tasks.register<Delete>("clean") { tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory) delete(rootProject.layout.buildDirectory)
} }
File diff suppressed because one or more lines are too long
+5
View File
@@ -1,2 +1,7 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true android.useAndroidX=true
android.suppressUnsupportedCompileSdk=36
# force all Flutter modules to compile against API 36
flutter.compileSdkVersion=36
kotlin.incremental=false
+3
View File
@@ -20,6 +20,9 @@ pluginManagement {
plugins { plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0" id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.11.1" apply false id("com.android.application") version "8.11.1" apply false
// START: FlutterFire Configuration
id("com.google.gms.google-services") version("4.3.15") apply false
// END: FlutterFire Configuration
id("org.jetbrains.kotlin.android") version "2.2.20" apply false id("org.jetbrains.kotlin.android") version "2.2.20" apply false
} }
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

+1
View File
@@ -0,0 +1 @@
{"flutter":{"platforms":{"android":{"default":{"projectId":"tasq-17fb3","appId":"1:173359574734:android:28f9a6792ea579ad2baa9f","fileOutput":"android/app/google-services.json"}},"dart":{"lib/firebase_options.dart":{"projectId":"tasq-17fb3","configurations":{"android":"1:173359574734:android:28f9a6792ea579ad2baa9f","ios":"1:173359574734:ios:2acd406a087240172baa9f","macos":"1:173359574734:ios:2acd406a087240172baa9f","web":"1:173359574734:web:f894a6b43a443e902baa9f","windows":"1:173359574734:web:c7603df3290c4a832baa9f"}}}}}}
+30
View File
@@ -1,8 +1,14 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_quill/flutter_quill.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'routing/app_router.dart'; import 'routing/app_router.dart';
import 'models/profile.dart';
import 'providers/profile_provider.dart';
import 'services/background_location_service.dart';
import 'theme/app_theme.dart'; import 'theme/app_theme.dart';
import 'utils/snackbar.dart';
class TasqApp extends ConsumerWidget { class TasqApp extends ConsumerWidget {
const TasqApp({super.key}); const TasqApp({super.key});
@@ -11,12 +17,36 @@ class TasqApp extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final router = ref.watch(appRouterProvider); final router = ref.watch(appRouterProvider);
// Ensure background service is running if the profile indicates tracking.
// This handles the case where the app restarts while tracking was already
// enabled (service should persist, but this guarantees it). It also stops
// the service when tracking is disabled externally.
ref.listen<AsyncValue<Profile?>>(currentProfileProvider, (
previous,
next,
) async {
final profile = next.valueOrNull;
final allow = profile?.allowTracking ?? false;
if (allow) {
await startBackgroundLocationUpdates();
} else {
await stopBackgroundLocationUpdates();
}
});
return MaterialApp.router( return MaterialApp.router(
title: 'TasQ', title: 'TasQ',
routerConfig: router, routerConfig: router,
scaffoldMessengerKey: scaffoldMessengerKey,
theme: AppTheme.light(), theme: AppTheme.light(),
darkTheme: AppTheme.dark(), darkTheme: AppTheme.dark(),
themeMode: ThemeMode.system, themeMode: ThemeMode.system,
localizationsDelegates: const [
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
FlutterQuillLocalizations.delegate,
],
); );
} }
} }
+86
View File
@@ -0,0 +1,86 @@
// File generated by FlutterFire CLI.
// ignore_for_file: type=lint
import 'package:firebase_core/firebase_core.dart' show FirebaseOptions;
import 'package:flutter/foundation.dart'
show defaultTargetPlatform, kIsWeb, TargetPlatform;
/// Default [FirebaseOptions] for use with your Firebase apps.
///
/// Example:
/// ```dart
/// import 'firebase_options.dart';
/// // ...
/// await Firebase.initializeApp(
/// options: DefaultFirebaseOptions.currentPlatform,
/// );
/// ```
class DefaultFirebaseOptions {
static FirebaseOptions get currentPlatform {
if (kIsWeb) {
return web;
}
switch (defaultTargetPlatform) {
case TargetPlatform.android:
return android;
case TargetPlatform.iOS:
return ios;
case TargetPlatform.macOS:
return macos;
case TargetPlatform.windows:
return windows;
case TargetPlatform.linux:
throw UnsupportedError(
'DefaultFirebaseOptions have not been configured for linux - '
'you can reconfigure this by running the FlutterFire CLI again.',
);
default:
throw UnsupportedError(
'DefaultFirebaseOptions are not supported for this platform.',
);
}
}
static const FirebaseOptions web = FirebaseOptions(
apiKey: 'AIzaSyBKGSaHYiqpZvbEgsvJJY45soiIkV6MV3M',
appId: '1:173359574734:web:f894a6b43a443e902baa9f',
messagingSenderId: '173359574734',
projectId: 'tasq-17fb3',
authDomain: 'tasq-17fb3.firebaseapp.com',
storageBucket: 'tasq-17fb3.firebasestorage.app',
);
static const FirebaseOptions android = FirebaseOptions(
apiKey: 'AIzaSyDt0ZYxfjXXxF4PS8NKbzOHFSHJC2LFvU4',
appId: '1:173359574734:android:28f9a6792ea579ad2baa9f',
messagingSenderId: '173359574734',
projectId: 'tasq-17fb3',
storageBucket: 'tasq-17fb3.firebasestorage.app',
);
static const FirebaseOptions ios = FirebaseOptions(
apiKey: 'AIzaSyCeOKj8Q_E45Vn2XrLmQekTPGaG3T-unS4',
appId: '1:173359574734:ios:2acd406a087240172baa9f',
messagingSenderId: '173359574734',
projectId: 'tasq-17fb3',
storageBucket: 'tasq-17fb3.firebasestorage.app',
iosBundleId: 'com.example.tasq',
);
static const FirebaseOptions macos = FirebaseOptions(
apiKey: 'AIzaSyCeOKj8Q_E45Vn2XrLmQekTPGaG3T-unS4',
appId: '1:173359574734:ios:2acd406a087240172baa9f',
messagingSenderId: '173359574734',
projectId: 'tasq-17fb3',
storageBucket: 'tasq-17fb3.firebasestorage.app',
iosBundleId: 'com.example.tasq',
);
static const FirebaseOptions windows = FirebaseOptions(
apiKey: 'AIzaSyBKGSaHYiqpZvbEgsvJJY45soiIkV6MV3M',
appId: '1:173359574734:web:c7603df3290c4a832baa9f',
messagingSenderId: '173359574734',
projectId: 'tasq-17fb3',
authDomain: 'tasq-17fb3.firebaseapp.com',
storageBucket: 'tasq-17fb3.firebasestorage.app',
);
}
+702 -16
View File
@@ -1,17 +1,265 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:audioplayers/audioplayers.dart'; import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pdfrx/pdfrx.dart';
import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:supabase_flutter/supabase_flutter.dart';
import 'screens/update_check_screen.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'firebase_options.dart';
// removed unused imports
import 'app.dart'; import 'app.dart';
import 'theme/app_theme.dart';
import 'providers/notifications_provider.dart'; import 'providers/notifications_provider.dart';
import 'providers/notification_navigation_provider.dart';
import 'utils/app_time.dart'; import 'utils/app_time.dart';
import 'utils/notification_permission.dart';
import 'utils/location_permission.dart';
import 'services/notification_service.dart';
import 'services/notification_bridge.dart';
import 'services/background_location_service.dart';
import 'services/app_update_service.dart';
import 'models/app_version.dart';
import 'widgets/update_dialog.dart';
import 'utils/navigation.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
// audio player not used at top-level; instantiate where needed
StreamSubscription<String?>? _fcmTokenRefreshSub;
late ProviderContainer _globalProviderContainer;
Map<String, String> _formatNotificationFromData(Map<String, dynamic> data) {
String actor = '';
if (data['actor_name'] != null) {
actor = data['actor_name'].toString();
} else if (data['mentioner_name'] != null) {
actor = data['mentioner_name'].toString();
} else if (data['user_name'] != null) {
actor = data['user_name'].toString();
} else if (data['from'] != null) {
actor = data['from'].toString();
} else if (data['actor'] != null) {
final a = data['actor'];
if (a is Map && a['name'] != null) {
actor = a['name'].toString();
} else if (a is String) {
try {
final parsed = jsonDecode(a);
if (parsed is Map && parsed['name'] != null) {
actor = parsed['name'].toString();
}
} catch (_) {
// ignore JSON parse errors
}
}
}
if (actor.isEmpty) {
actor = 'Someone';
}
final taskNumber =
(data['task_number'] ?? data['taskNumber'] ?? data['task_no'])
?.toString() ??
'';
final taskId =
(data['task_id'] ?? data['taskId'] ?? data['task'])?.toString() ?? '';
final ticketId =
data['ticket_id'] ?? data['ticketId'] ?? data['ticket'] ?? '';
final type = (data['type'] ?? '').toString().toLowerCase();
final taskLabel = taskNumber.isNotEmpty
? 'Task $taskNumber'
: (taskId.isNotEmpty ? 'Task #$taskId' : 'Task');
if ((taskId.isNotEmpty || taskNumber.isNotEmpty) &&
(type.contains('assign') ||
data['action'] == 'assign' ||
data['assigned'] == 'true')) {
return {
'title': 'Task assigned',
'body': '$actor has assigned you $taskLabel',
};
}
if ((taskId.isNotEmpty || taskNumber.isNotEmpty) &&
(type.contains('mention') ||
data['action'] == 'mention' ||
data['mentioned'] == 'true')) {
return {
'title': 'Mention',
'body': '$actor has mentioned you in $taskLabel',
};
}
if (ticketId.isNotEmpty &&
(type.contains('mention') || data['action'] == 'mention')) {
return {
'title': 'Mention',
'body': '$actor has mentioned you in Ticket #$ticketId',
};
}
// Fallback to supplied title/body or generic
final title = data['title']?.toString() ?? 'New Notification';
final body = data['body']?.toString() ?? 'You have a new update in TasQ.';
return {'title': title, 'body': body};
}
// Initialize the plugin
final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin =
FlutterLocalNotificationsPlugin();
/// Handle messages received while the app is terminated or in background.
@pragma('vm:entry-point') // Required for background execution
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
// 1. Initialize the plugin inside the background isolate
final FlutterLocalNotificationsPlugin localNotifPlugin =
FlutterLocalNotificationsPlugin();
// 2. Extract and format title/body from the DATA payload (not message.notification)
final formatted = _formatNotificationFromData(message.data);
final String title = formatted['title']!;
final String body = formatted['body']!;
// Determine a stable ID for deduplication (prefer server-provided id)
String? stableId =
(message.data['notification_id'] as String?) ?? message.messageId;
if (stableId == null) {
final sb = StringBuffer();
final taskNumber =
(message.data['task_number'] ??
message.data['taskNumber'] ??
message.data['task_no'])
?.toString();
final taskId =
(message.data['task_id'] ??
message.data['taskId'] ??
message.data['task'])
?.toString();
final ticketId =
(message.data['ticket_id'] ??
message.data['ticketId'] ??
message.data['ticket'])
?.toString();
final type = (message.data['type'] ?? '').toString();
final actorId =
(message.data['actor_id'] ??
message.data['actorId'] ??
message.data['actor'])
?.toString();
if (taskNumber != null && taskNumber.isNotEmpty) {
sb.write('tasknum:$taskNumber');
} else if (taskId != null && taskId.isNotEmpty) {
sb.write('task:$taskId');
}
if (ticketId != null && ticketId.isNotEmpty) {
if (sb.isNotEmpty) sb.write('|');
sb.write('ticket:$ticketId');
}
if (type.isNotEmpty) {
if (sb.isNotEmpty) sb.write('|');
sb.write('type:$type');
}
if (actorId != null && actorId.isNotEmpty) {
if (sb.isNotEmpty) sb.write('|');
sb.write('actor:$actorId');
}
stableId = sb.isNotEmpty
? sb.toString()
: (DateTime.now().millisecondsSinceEpoch ~/ 1000).toString();
}
// Dedupe: keep a short-lived cache of recent notification IDs to avoid duplicates
try {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString('recent_notifs') ?? '{}';
final Map<String, dynamic> recent = jsonDecode(raw);
final int now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
const int ttl = 60; // seconds
// prune old entries
recent.removeWhere(
(k, v) => (v is int ? v : int.parse(v.toString())) < now - ttl,
);
if (recent.containsKey(stableId)) {
// already shown recently skip
return;
}
recent[stableId] = now;
await prefs.setString('recent_notifs', jsonEncode(recent));
} catch (e) {
// If prefs fail in background isolate, fall back to showing notification
}
// Create a unique ID for the notification display
final int id = DateTime.now().millisecondsSinceEpoch ~/ 1000;
// Build payload string with ticket/task information for navigation
final payloadParts = <String>[];
final taskId =
(message.data['task_id'] ??
message.data['taskId'] ??
message.data['task'])
?.toString();
final ticketId =
message.data['ticket_id'] ??
message.data['ticketId'] ??
message.data['ticket']?.toString() ??
'';
if (taskId != null && taskId.isNotEmpty) {
payloadParts.add('task:$taskId');
}
if (ticketId.isNotEmpty) {
payloadParts.add('ticket:$ticketId');
}
final payload = payloadParts.join('|').isNotEmpty
? payloadParts.join('|')
: message.data['type']?.toString() ?? '';
// 3. Define the exact same channel specifics
const androidDetails = AndroidNotificationDetails(
'tasq_custom_sound_channel_3',
'High Importance Notifications',
importance: Importance.max,
priority: Priority.high,
playSound: true,
sound: RawResourceAndroidNotificationSound('tasq_notification'),
);
// 4. Show the notification manually
await localNotifPlugin.show(
id: id,
title: title,
body: body,
notificationDetails: const NotificationDetails(android: androidDetails),
payload: payload,
);
}
Future<void> main() async { Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
// The flag optionally hides annoying WASM warnings in your Chrome dev console
pdfrxFlutterInitialize(dismissPdfiumWasmWarnings: true);
await dotenv.load(fileName: '.env'); // initialize Firebase before anything that uses messaging
try {
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
).timeout(const Duration(seconds: 15));
} catch (e) {
debugPrint('Firebase init failed or timed out: $e');
}
FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
try {
await dotenv.load(fileName: '.env').timeout(const Duration(seconds: 5));
} catch (e) {
debugPrint('dotenv load failed or timed out: $e');
}
AppTime.initialize(location: 'Asia/Manila'); AppTime.initialize(location: 'Asia/Manila');
@@ -23,19 +271,417 @@ Future<void> main() async {
return; return;
} }
await Supabase.initialize(url: supabaseUrl, anonKey: supabaseAnonKey); try {
await Supabase.initialize(
url: supabaseUrl,
anonKey: supabaseAnonKey,
).timeout(const Duration(seconds: 20));
} catch (e) {
debugPrint('Supabase init failed or timed out: $e');
runApp(const _MissingConfigApp());
return;
}
// Initialize background location service (flutter_background_service)
if (!kIsWeb) {
try {
await initBackgroundLocationService().timeout(
const Duration(seconds: 10),
);
} catch (e) {
debugPrint('Background location service init failed or timed out: $e');
}
}
// ensure token saved shortly after startup if already signed in.
// Run this after runApp so startup is not blocked by network/token ops.
final supaClient = Supabase.instance.client;
// listen for auth changes to register/unregister token accordingly
supaClient.auth.onAuthStateChange.listen((data) async {
final event = data.event;
if (kIsWeb) {
debugPrint(
'auth state change $event on web: skipping FCM token handling',
);
return;
}
String? token;
try {
token = await FirebaseMessaging.instance.getToken();
} catch (e) {
debugPrint('FCM getToken failed during auth change: $e');
token = null;
}
debugPrint('auth state change $event, token=$token');
if (token == null) return;
final ctrl = NotificationsController(supaClient);
if (event == AuthChangeEvent.signedIn) {
// register current token and ensure we listen for refreshes
await ctrl.registerFcmToken(token);
try {
// cancel any previous subscription
await _fcmTokenRefreshSub?.cancel();
} catch (_) {}
_fcmTokenRefreshSub = FirebaseMessaging.instance.onTokenRefresh.listen((
t,
) {
debugPrint('token refreshed (auth listener): $t');
ctrl.registerFcmToken(t);
});
} else if (event == AuthChangeEvent.signedOut) {
// cancel token refresh subscription and unregister
try {
await _fcmTokenRefreshSub?.cancel();
} catch (_) {}
_fcmTokenRefreshSub = null;
await ctrl.unregisterFcmToken(token);
}
});
if (!kIsWeb) {
// on Android 13+ we must request POST_NOTIFICATIONS at runtime; without it
// notifications are automatically denied and cannot be reenabled from the
// system settings. The helper uses `permission_handler`.
try {
final granted = await ensureNotificationPermission().timeout(
const Duration(seconds: 10),
);
if (!granted) {
// we don't block startup, but it's worth logging so developers notice.
// debugPrint('notification permission not granted');
}
} catch (e) {
debugPrint('Notification permission request failed or timed out: $e');
}
// Request location permission at launch (same pattern as notification)
try {
final locationGranted = await ensureLocationPermission().timeout(
const Duration(seconds: 10),
);
if (!locationGranted) {
// debugPrint('location permission not granted');
}
} catch (e) {
debugPrint('Location permission request failed or timed out: $e');
}
// request FCM permission (iOS/Android13+) and handle foreground messages
try {
await FirebaseMessaging.instance.requestPermission().timeout(
const Duration(seconds: 10),
);
} catch (e) {
debugPrint('FCM permission request failed or timed out: $e');
}
}
FirebaseMessaging.onMessage.listen((RemoteMessage message) async {
// Prefer the data payload and format friendly messages, with dedupe.
// If actor_name is not present but actor_id is, try to resolve the
// display name using the Supabase client (foreground only).
Map<String, dynamic> dataForFormatting = Map<String, dynamic>.from(
message.data,
);
try {
final hasActorName =
(dataForFormatting['actor_name'] ??
dataForFormatting['mentioner_name'] ??
dataForFormatting['user_name'] ??
dataForFormatting['from'] ??
dataForFormatting['actor']) !=
null;
final actorId =
dataForFormatting['actor_id'] ??
dataForFormatting['actorId'] ??
dataForFormatting['actor'];
if (!hasActorName && actorId is String && actorId.isNotEmpty) {
try {
final client = Supabase.instance.client;
final res = await client
.from('profiles')
.select('full_name,display_name,name')
.eq('id', actorId)
.maybeSingle();
if (res != null) {
String? name;
if (res['full_name'] != null) {
name = res['full_name'].toString();
} else if (res['display_name'] != null) {
name = res['display_name'].toString();
} else if (res['name'] != null) {
name = res['name'].toString();
}
if (name != null && name.isNotEmpty) {
dataForFormatting['actor_name'] = name;
}
}
} catch (_) {
// ignore lookup failures and fall back to data payload
}
}
} catch (_) {}
final formatted = _formatNotificationFromData(dataForFormatting);
String? stableId =
(message.data['notification_id'] as String?) ?? message.messageId;
if (stableId == null) {
final sb = StringBuffer();
final taskNumber =
(message.data['task_number'] ??
message.data['taskNumber'] ??
message.data['task_no'])
?.toString();
final taskId =
(message.data['task_id'] ??
message.data['taskId'] ??
message.data['task'])
?.toString();
final ticketId =
(message.data['ticket_id'] ??
message.data['ticketId'] ??
message.data['ticket'])
?.toString();
final type = (message.data['type'] ?? '').toString();
final actorId =
(message.data['actor_id'] ??
message.data['actorId'] ??
message.data['actor'])
?.toString();
if (taskNumber != null && taskNumber.isNotEmpty) {
sb.write('tasknum:$taskNumber');
} else if (taskId != null && taskId.isNotEmpty) {
sb.write('task:$taskId');
}
if (ticketId != null && ticketId.isNotEmpty) {
if (sb.isNotEmpty) sb.write('|');
sb.write('ticket:$ticketId');
}
if (type.isNotEmpty) {
if (sb.isNotEmpty) sb.write('|');
sb.write('type:$type');
}
if (actorId != null && actorId.isNotEmpty) {
if (sb.isNotEmpty) sb.write('|');
sb.write('actor:$actorId');
}
stableId = sb.isNotEmpty
? sb.toString()
: (DateTime.now().millisecondsSinceEpoch ~/ 1000).toString();
}
try {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString('recent_notifs') ?? '{}';
final Map<String, dynamic> recent = jsonDecode(raw);
final int now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
const int ttl = 60;
recent.removeWhere(
(k, v) => (v is int ? v : int.parse(v.toString())) < now - ttl,
);
if (!recent.containsKey(stableId)) {
recent[stableId] = now;
await prefs.setString('recent_notifs', jsonEncode(recent));
// Build payload string with ticket/task information for navigation
final payloadParts = <String>[];
final taskId =
(message.data['task_id'] ??
message.data['taskId'] ??
message.data['task'])
?.toString();
final ticketId =
message.data['ticket_id'] ??
message.data['ticketId'] ??
message.data['ticket']?.toString() ??
'';
if (taskId != null && taskId.isNotEmpty) {
payloadParts.add('task:$taskId');
}
if (ticketId.isNotEmpty) {
payloadParts.add('ticket:$ticketId');
}
final payload = payloadParts.join('|').isNotEmpty
? payloadParts.join('|')
: message.data['payload']?.toString() ?? '';
NotificationService.show(
id: DateTime.now().millisecondsSinceEpoch ~/ 1000,
title: formatted['title']!,
body: formatted['body']!,
payload: payload,
);
}
} catch (e) {
// On failure, just show the notification to avoid dropping alerts
NotificationService.show(
id: DateTime.now().millisecondsSinceEpoch ~/ 1000,
title: formatted['title']!,
body: formatted['body']!,
payload: '',
);
}
});
// 1. Define the High Importance Channel (This MUST match your manifest exactly)
const AndroidNotificationChannel channel = AndroidNotificationChannel(
'tasq_custom_sound_channel', // id
'High Importance Notifications', // title visible to user in phone settings
description: 'This channel is used for important TasQ notifications.',
importance:
Importance.max, // THIS is what forces the sound and heads-up banner
playSound: true,
// 👇 Tell Android to use your specific file in the raw folder
sound: RawResourceAndroidNotificationSound('tasq_notification'),
);
// 2. Create the channel on the device
await flutterLocalNotificationsPlugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>()
?.createNotificationChannel(channel);
// Create the global provider container BEFORE initializing local
// notifications, because the tap callback needs to write to it and
// flutter_local_notifications may fire the callback synchronously if
// the app was launched by tapping a notification.
_globalProviderContainer = ProviderContainer(
observers: [NotificationSoundObserver()],
);
// initialize the local notifications plugin so we can post alerts later
await NotificationService.initialize(
onDidReceiveNotificationResponse: (response) {
// handle user tapping a notification; the payload format is "ticket:ID",
// "task:ID", "tasknum:NUMBER", or a combination separated by "|"
final payload = response.payload;
if (payload != null && payload.isNotEmpty) {
// Parse the payload to extract ticket and task information
final parts = payload.split('|');
String? ticketId;
String? taskId;
for (final part in parts) {
if (part.startsWith('ticket:')) {
ticketId = part.substring('ticket:'.length);
} else if (part.startsWith('task:')) {
taskId = part.substring('task:'.length);
}
}
// Update the pending navigation provider.
// Prefer task over ticket assignment notifications include both
// IDs but the primary entity is the task.
if (taskId != null && taskId.isNotEmpty) {
_globalProviderContainer
.read(pendingNotificationNavigationProvider.notifier)
.state = (
type: 'task',
id: taskId,
);
} else if (ticketId != null && ticketId.isNotEmpty) {
_globalProviderContainer
.read(pendingNotificationNavigationProvider.notifier)
.state = (
type: 'ticket',
id: ticketId,
);
}
}
},
);
runApp( runApp(
ProviderScope( UncontrolledProviderScope(
observers: [NotificationSoundObserver()], container: _globalProviderContainer,
child: const TasqApp(), child: const UpdateCheckWrapper(),
), ),
); );
// Post-startup registration removed: token registration is handled
// centrally in the auth state change listener to avoid duplicate inserts.
}
/// Wrapper shown at app launch; performs update check and displays
/// [UpdateCheckingScreen] until complete.
class UpdateCheckWrapper extends StatefulWidget {
const UpdateCheckWrapper({super.key});
@override
State<UpdateCheckWrapper> createState() => _UpdateCheckWrapperState();
}
class _UpdateCheckWrapperState extends State<UpdateCheckWrapper> {
bool _done = false;
Future<AppUpdateInfo> _checkForUpdates() =>
AppUpdateService.instance.checkForUpdate();
Future<void> _handleUpdateComplete(AppUpdateInfo? info) async {
if (!mounted) return;
if (info?.isUpdateAvailable == true) {
// Keep the update-check screen visible while the dialog is shown.
// Use a safe context reference; fall back to the wrapper's own context.
final dialogContext = globalNavigatorKey.currentContext ?? context;
try {
await showDialog(
context: dialogContext,
barrierDismissible: !(info?.isForceUpdate ?? false),
builder: (_) => UpdateDialog(info: info!),
);
} catch (_) {
// If the dialog fails (rare), continue to the next screen.
}
}
if (!mounted) return;
setState(() {
_done = true;
});
}
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
theme: AppTheme.light(),
darkTheme: AppTheme.dark(),
themeMode: ThemeMode.system,
home: AnimatedSwitcher(
duration: const Duration(milliseconds: 420),
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
transitionBuilder: (child, animation) {
final curved = CurvedAnimation(
parent: animation,
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeInCubic,
);
return FadeTransition(
opacity: curved,
child: ScaleTransition(
scale: Tween<double>(begin: 0.94, end: 1.0).animate(curved),
child: child,
),
);
},
child: kIsWeb
? const NotificationBridge(child: TasqApp())
: (_done
? const NotificationBridge(child: TasqApp())
: UpdateCheckingScreen(
checkForUpdates: _checkForUpdates,
onCompleted: _handleUpdateComplete,
)),
),
);
}
} }
class NotificationSoundObserver extends ProviderObserver { class NotificationSoundObserver extends ProviderObserver {
static final AudioPlayer _player = AudioPlayer();
@override @override
void didUpdateProvider( void didUpdateProvider(
ProviderBase provider, ProviderBase provider,
@@ -43,16 +689,56 @@ class NotificationSoundObserver extends ProviderObserver {
Object? newValue, Object? newValue,
ProviderContainer container, ProviderContainer container,
) { ) {
if (provider == unreadNotificationsCountProvider) { // play sound + show OS notification on unread-count increase
final prev = previousValue as int?; // if (provider == unreadNotificationsCountProvider) {
final next = newValue as int?; // final prev = previousValue as int?;
if (prev != null && next != null && next > prev) { // final next = newValue as int?;
_player.play(AssetSource('tasq_notification.wav')); // if (prev != null && next != null && next > prev) {
} // _maybeShowUnreadNotification(next);
} // }
// }
// Profile changes no longer perform token registration here.
// Token registration is handled centrally in the auth state change listener
// to avoid duplicate DB rows and duplicate deliveries.
} }
} }
// void _maybeShowUnreadNotification(int next) async {
// try {
// final prefs = await SharedPreferences.getInstance();
// final raw = prefs.getString('recent_notifs') ?? '{}';
// final Map<String, dynamic> recent = jsonDecode(raw);
// final int now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
// const int ttl = 60; // seconds
// // prune old entries
// recent.removeWhere(
// (k, v) => (v is int ? v : int.parse(v.toString())) < now - ttl,
// );
// // if there is any recent notification (from server or other), skip duplicating
// if (recent.isNotEmpty) return;
// // mark a synthetic unread-notif to prevent immediate duplicates
// recent['unread_summary'] = now;
// await prefs.setString('recent_notifs', jsonEncode(recent));
// _player.play(AssetSource('tasq_notification.wav'));
// NotificationService.show(
// id: DateTime.now().millisecondsSinceEpoch ~/ 1000,
// title: 'New notifications',
// body: 'You have $next unread notifications.',
// );
// } catch (e) {
// // fallback: show notification
// _player.play(AssetSource('tasq_notification.wav'));
// NotificationService.show(
// id: DateTime.now().millisecondsSinceEpoch ~/ 1000,
// title: 'New notifications',
// body: 'You have $next unread notifications.',
// );
// }
// }
class _MissingConfigApp extends StatelessWidget { class _MissingConfigApp extends StatelessWidget {
const _MissingConfigApp(); const _MissingConfigApp();
+19
View File
@@ -81,3 +81,22 @@ class AppSetting {
); );
} }
} }
class RamadanConfig {
RamadanConfig({required this.enabled, required this.autoDetect});
final bool enabled;
final bool autoDetect;
factory RamadanConfig.fromJson(Map<String, dynamic> json) {
return RamadanConfig(
enabled: json['enabled'] as bool? ?? false,
autoDetect: json['auto_detect'] as bool? ?? true,
);
}
Map<String, dynamic> toJson() => {
'enabled': enabled,
'auto_detect': autoDetect,
};
}
+61
View File
@@ -0,0 +1,61 @@
/// Represents a row in the ``app_versions`` table on the server. The
/// `AppUpdateService` fetches the most recent entry and compares it against the
/// running application's build number.
class AppVersion {
/// Version string, e.g. `1.2.3` or `2026.03.12`. Stored as text in
/// the database so semantic versions are allowed.
final String versionCode;
/// If the device is running a build number that is *strictly less* than this
/// value the update is considered "forced" and the user cannot continue
/// using the existing install. Also a string.
final String minVersionRequired;
/// A publiclyaccessible URL pointing at an APK stored in Supabase Storage.
final String downloadUrl;
/// Markdown/plaintext release notes that will be shown in the dialog.
final String releaseNotes;
AppVersion({
required this.versionCode,
required this.minVersionRequired,
required this.downloadUrl,
required this.releaseNotes,
});
factory AppVersion.fromMap(Map<String, dynamic> map) {
return AppVersion(
versionCode: map['version_code']?.toString() ?? '',
minVersionRequired: map['min_version_required']?.toString() ?? '',
downloadUrl: map['download_url'] as String,
releaseNotes: map['release_notes'] as String? ?? '',
);
}
}
/// Helper type returned by ``AppUpdateService.checkForUpdate`` so callers can
/// decide how to present the UI.
class AppUpdateInfo {
/// The version that was returned from ``app_versions``. ``null`` when the
/// table was empty (should not happen in normal operation).
final AppVersion? latestVersion;
/// Current build number / version string. May be empty on non-Android
/// platforms since checkForUpdate is skipped there.
final String currentBuildNumber;
/// ``true`` when ``currentBuildNumber < latestVersion.versionCode``.
final bool isUpdateAvailable;
/// ``true`` when ``currentBuildNumber < latestVersion.minVersionRequired``
/// (regardless of whether there is a newer release available).
final bool isForceUpdate;
AppUpdateInfo({
required this.currentBuildNumber,
required this.latestVersion,
required this.isUpdateAvailable,
required this.isForceUpdate,
});
}
+68
View File
@@ -0,0 +1,68 @@
import '../utils/app_time.dart';
class AttendanceLog {
AttendanceLog({
required this.id,
required this.userId,
required this.dutyScheduleId,
required this.shiftType,
required this.checkInAt,
required this.checkInLat,
required this.checkInLng,
this.checkOutAt,
this.checkOutLat,
this.checkOutLng,
this.justification,
this.checkOutJustification,
this.verificationStatus = 'pending',
this.checkInVerificationPhotoUrl,
this.checkOutVerificationPhotoUrl,
});
final String id;
final String userId;
final String dutyScheduleId;
final String shiftType;
final DateTime checkInAt;
final double checkInLat;
final double checkInLng;
final DateTime? checkOutAt;
final double? checkOutLat;
final double? checkOutLng;
final String? justification;
final String? checkOutJustification;
final String verificationStatus; // pending, verified, unverified, skipped
final String? checkInVerificationPhotoUrl;
final String? checkOutVerificationPhotoUrl;
bool get isCheckedOut => checkOutAt != null;
bool get isVerified => verificationStatus == 'verified';
bool get isUnverified =>
verificationStatus == 'unverified' || verificationStatus == 'skipped';
factory AttendanceLog.fromMap(Map<String, dynamic> map) {
// Support both old single-column and new dual-column schemas.
final legacyUrl = map['verification_photo_url'] as String?;
return AttendanceLog(
id: map['id'] as String,
userId: map['user_id'] as String,
dutyScheduleId: map['duty_schedule_id'] as String,
shiftType: map['shift_type'] as String? ?? 'normal',
checkInAt: AppTime.parse(map['check_in_at'] as String),
checkInLat: (map['check_in_lat'] as num).toDouble(),
checkInLng: (map['check_in_lng'] as num).toDouble(),
checkOutAt: map['check_out_at'] == null
? null
: AppTime.parse(map['check_out_at'] as String),
checkOutLat: (map['check_out_lat'] as num?)?.toDouble(),
checkOutLng: (map['check_out_lng'] as num?)?.toDouble(),
justification: map['justification'] as String?,
checkOutJustification: map['check_out_justification'] as String?,
verificationStatus: map['verification_status'] as String? ?? 'pending',
checkInVerificationPhotoUrl:
map['check_in_verification_photo_url'] as String? ?? legacyUrl,
checkOutVerificationPhotoUrl:
map['check_out_verification_photo_url'] as String?,
);
}
}
+33
View File
@@ -0,0 +1,33 @@
import '../utils/app_time.dart';
class ChatMessage {
ChatMessage({
required this.id,
required this.threadId,
required this.senderId,
required this.body,
required this.createdAt,
});
final String id;
final String threadId;
final String senderId;
final String body;
final DateTime createdAt;
factory ChatMessage.fromMap(Map<String, dynamic> map) {
return ChatMessage(
id: map['id'] as String,
threadId: map['thread_id'] as String,
senderId: map['sender_id'] as String,
body: map['body'] as String,
createdAt: AppTime.parse(map['created_at'] as String),
);
}
Map<String, dynamic> toJson() => {
'thread_id': threadId,
'sender_id': senderId,
'body': body,
};
}
+259
View File
@@ -0,0 +1,259 @@
import 'dart:convert';
import '../utils/app_time.dart';
/// Available IT services from the form.
class ItServiceType {
static const fbLiveStream = 'fb_live_stream';
static const videoRecording = 'video_recording';
static const technicalAssistance = 'technical_assistance';
static const wifi = 'wifi';
static const others = 'others';
static const all = [
fbLiveStream,
videoRecording,
technicalAssistance,
wifi,
others,
];
static String label(String type) {
switch (type) {
case fbLiveStream:
return 'FB Live Stream';
case videoRecording:
return 'Video Recording';
case technicalAssistance:
return 'Technical Assistance';
case wifi:
return 'WiFi';
case others:
return 'Others';
default:
return type;
}
}
}
/// Status lifecycle for an IT Service Request.
class ItServiceRequestStatus {
static const draft = 'draft';
static const pendingApproval = 'pending_approval';
static const scheduled = 'scheduled';
static const inProgressDryRun = 'in_progress_dry_run';
static const inProgress = 'in_progress';
static const completed = 'completed';
static const cancelled = 'cancelled';
static const all = [
draft,
pendingApproval,
scheduled,
inProgressDryRun,
inProgress,
completed,
cancelled,
];
static String label(String status) {
switch (status) {
case draft:
return 'Draft';
case pendingApproval:
return 'Pending Approval';
case scheduled:
return 'Scheduled';
case inProgressDryRun:
return 'In Progress (Dry Run)';
case inProgress:
return 'In Progress';
case completed:
return 'Completed';
case cancelled:
return 'Cancelled';
default:
return status;
}
}
}
class ItServiceRequest {
ItServiceRequest({
required this.id,
this.requestNumber,
required this.services,
this.servicesOther,
required this.eventName,
this.eventDetails,
this.eventDate,
this.eventEndDate,
this.dryRunDate,
this.dryRunEndDate,
this.contactPerson,
this.contactNumber,
this.remarks,
this.officeId,
this.requestedBy,
this.requestedByUserId,
this.approvedBy,
this.approvedByUserId,
this.approvedAt,
required this.status,
required this.outsidePremiseAllowed,
this.cancellationReason,
this.cancelledAt,
this.creatorId,
required this.createdAt,
required this.updatedAt,
this.completedAt,
this.dateTimeReceived,
this.dateTimeChecked,
});
final String id;
final String? requestNumber;
final List<String> services;
final String? servicesOther;
final String eventName;
final String? eventDetails; // Quill Delta JSON
final DateTime? eventDate;
final DateTime? eventEndDate;
final DateTime? dryRunDate;
final DateTime? dryRunEndDate;
final String? contactPerson;
final String? contactNumber;
final String? remarks; // Quill Delta JSON
final String? officeId;
final String? requestedBy;
final String? requestedByUserId;
final String? approvedBy;
final String? approvedByUserId;
final DateTime? approvedAt;
final String status;
final bool outsidePremiseAllowed;
final String? cancellationReason;
final DateTime? cancelledAt;
final String? creatorId;
final DateTime createdAt;
final DateTime updatedAt;
final DateTime? completedAt;
final DateTime? dateTimeReceived;
final DateTime? dateTimeChecked;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ItServiceRequest &&
runtimeType == other.runtimeType &&
id == other.id &&
requestNumber == other.requestNumber &&
status == other.status &&
updatedAt == other.updatedAt;
@override
int get hashCode => Object.hash(id, requestNumber, status, updatedAt);
/// Whether the request is on a day that would allow geofence override.
bool isGeofenceOverrideActive(DateTime now) {
if (!outsidePremiseAllowed) return false;
final today = DateTime(now.year, now.month, now.day);
// Active on dry run date or event date
if (dryRunDate != null) {
final dryDay = DateTime(
dryRunDate!.year,
dryRunDate!.month,
dryRunDate!.day,
);
if (!today.isBefore(dryDay) &&
today.isBefore(dryDay.add(const Duration(days: 1)))) {
return true;
}
}
if (eventDate != null) {
final eventDay = DateTime(
eventDate!.year,
eventDate!.month,
eventDate!.day,
);
if (!today.isBefore(eventDay) &&
today.isBefore(eventDay.add(const Duration(days: 1)))) {
return true;
}
}
return false;
}
factory ItServiceRequest.fromMap(Map<String, dynamic> map) {
List<String> parseServices(dynamic raw) {
if (raw is List) return raw.map((e) => e.toString()).toList();
if (raw is String) {
// Handle PostgreSQL array format: {a,b,c}
final trimmed = raw.replaceAll('{', '').replaceAll('}', '');
if (trimmed.isEmpty) return [];
return trimmed.split(',').map((e) => e.trim()).toList();
}
return [];
}
String? quillField(dynamic raw) {
if (raw == null) return null;
if (raw is String) return raw;
try {
return jsonEncode(raw);
} catch (_) {
return raw.toString();
}
}
return ItServiceRequest(
id: map['id'] as String,
requestNumber: map['request_number'] as String?,
services: parseServices(map['services']),
servicesOther: map['services_other'] as String?,
eventName: map['event_name'] as String? ?? '',
eventDetails: quillField(map['event_details']),
eventDate: map['event_date'] == null
? null
: AppTime.parse(map['event_date'] as String),
eventEndDate: map['event_end_date'] == null
? null
: AppTime.parse(map['event_end_date'] as String),
dryRunDate: map['dry_run_date'] == null
? null
: AppTime.parse(map['dry_run_date'] as String),
dryRunEndDate: map['dry_run_end_date'] == null
? null
: AppTime.parse(map['dry_run_end_date'] as String),
contactPerson: map['contact_person'] as String?,
contactNumber: map['contact_number'] as String?,
remarks: quillField(map['remarks']),
officeId: map['office_id'] as String?,
requestedBy: map['requested_by'] as String?,
requestedByUserId: map['requested_by_user_id'] as String?,
approvedBy: map['approved_by'] as String?,
approvedByUserId: map['approved_by_user_id'] as String?,
approvedAt: map['approved_at'] == null
? null
: AppTime.parse(map['approved_at'] as String),
status: map['status'] as String? ?? 'draft',
outsidePremiseAllowed: map['outside_premise_allowed'] as bool? ?? false,
cancellationReason: map['cancellation_reason'] as String?,
cancelledAt: map['cancelled_at'] == null
? null
: AppTime.parse(map['cancelled_at'] as String),
creatorId: map['creator_id'] as String?,
createdAt: AppTime.parse(map['created_at'] as String),
updatedAt: AppTime.parse(map['updated_at'] as String),
completedAt: map['completed_at'] == null
? null
: AppTime.parse(map['completed_at'] as String),
dateTimeReceived: map['date_time_received'] == null
? null
: AppTime.parse(map['date_time_received'] as String),
dateTimeChecked: map['date_time_checked'] == null
? null
: AppTime.parse(map['date_time_checked'] as String),
);
}
}
+42
View File
@@ -0,0 +1,42 @@
import 'dart:convert';
import '../utils/app_time.dart';
class ItServiceRequestAction {
ItServiceRequestAction({
required this.id,
required this.requestId,
required this.userId,
this.actionTaken,
required this.createdAt,
required this.updatedAt,
});
final String id;
final String requestId;
final String userId;
final String? actionTaken; // Quill Delta JSON
final DateTime createdAt;
final DateTime updatedAt;
factory ItServiceRequestAction.fromMap(Map<String, dynamic> map) {
String? quillField(dynamic raw) {
if (raw == null) return null;
if (raw is String) return raw;
try {
return jsonEncode(raw);
} catch (_) {
return raw.toString();
}
}
return ItServiceRequestAction(
id: map['id'] as String,
requestId: map['request_id'] as String,
userId: map['user_id'] as String,
actionTaken: quillField(map['action_taken']),
createdAt: AppTime.parse(map['created_at'] as String),
updatedAt: AppTime.parse(map['updated_at'] as String),
);
}
}
@@ -0,0 +1,74 @@
import 'dart:convert';
import '../utils/app_time.dart';
class ItServiceRequestActivityLog {
ItServiceRequestActivityLog({
required this.id,
required this.requestId,
this.actorId,
required this.actionType,
this.meta,
required this.createdAt,
});
final String id;
final String requestId;
final String? actorId;
final String actionType;
final Map<String, dynamic>? meta;
final DateTime createdAt;
factory ItServiceRequestActivityLog.fromMap(Map<String, dynamic> map) {
final rawId = map['id'];
final rawRequestId = map['request_id'];
String id = rawId == null ? '' : rawId.toString();
String requestId = rawRequestId == null ? '' : rawRequestId.toString();
final actorId = map['actor_id']?.toString();
final actionType = (map['action_type'] as String?) ?? 'unknown';
Map<String, dynamic>? meta;
final rawMeta = map['meta'];
if (rawMeta is Map<String, dynamic>) {
meta = rawMeta;
} else if (rawMeta is Map) {
try {
meta = rawMeta.map((k, v) => MapEntry(k.toString(), v));
} catch (_) {
meta = null;
}
} else if (rawMeta is String && rawMeta.isNotEmpty) {
try {
final decoded = jsonDecode(rawMeta);
if (decoded is Map<String, dynamic>) {
meta = decoded;
} else if (decoded is Map) {
meta = decoded.map((k, v) => MapEntry(k.toString(), v));
}
} catch (_) {
meta = null;
}
}
DateTime createdAt;
final rawCreated = map['created_at'];
if (rawCreated is String) {
try {
createdAt = AppTime.parse(rawCreated);
} catch (_) {
createdAt = AppTime.now();
}
} else {
createdAt = AppTime.now();
}
return ItServiceRequestActivityLog(
id: id,
requestId: requestId,
actorId: actorId,
actionType: actionType,
meta: meta,
createdAt: createdAt,
);
}
}
@@ -0,0 +1,24 @@
import '../utils/app_time.dart';
class ItServiceRequestAssignment {
ItServiceRequestAssignment({
required this.id,
required this.requestId,
required this.userId,
required this.createdAt,
});
final String id;
final String requestId;
final String userId;
final DateTime createdAt;
factory ItServiceRequestAssignment.fromMap(Map<String, dynamic> map) {
return ItServiceRequestAssignment(
id: map['id'] as String,
requestId: map['request_id'] as String,
userId: map['user_id'] as String,
createdAt: AppTime.parse(map['created_at'] as String),
);
}
}
+54
View File
@@ -0,0 +1,54 @@
import '../utils/app_time.dart';
class LeaveOfAbsence {
LeaveOfAbsence({
required this.id,
required this.userId,
required this.leaveType,
required this.justification,
required this.startTime,
required this.endTime,
required this.status,
required this.filedBy,
required this.createdAt,
});
final String id;
final String userId;
final String leaveType;
final String justification;
final DateTime startTime;
final DateTime endTime;
final String status;
final String filedBy;
final DateTime createdAt;
factory LeaveOfAbsence.fromMap(Map<String, dynamic> map) {
return LeaveOfAbsence(
id: map['id'] as String,
userId: map['user_id'] as String,
leaveType: map['leave_type'] as String,
justification: map['justification'] as String,
startTime: AppTime.parse(map['start_time'] as String),
endTime: AppTime.parse(map['end_time'] as String),
status: map['status'] as String? ?? 'pending',
filedBy: map['filed_by'] as String,
createdAt: AppTime.parse(map['created_at'] as String),
);
}
String get leaveTypeLabel {
switch (leaveType) {
case 'emergency_leave':
return 'Emergency Leave';
case 'parental_leave':
return 'Parental Leave';
case 'sick_leave':
return 'Sick Leave';
case 'vacation_leave':
return 'Vacation Leave';
default:
return leaveType;
}
}
}
+27
View File
@@ -0,0 +1,27 @@
import '../utils/app_time.dart';
class LivePosition {
LivePosition({
required this.userId,
required this.lat,
required this.lng,
required this.updatedAt,
required this.inPremise,
});
final String userId;
final double lat;
final double lng;
final DateTime updatedAt;
final bool inPremise;
factory LivePosition.fromMap(Map<String, dynamic> map) {
return LivePosition(
userId: map['user_id'] as String,
lat: (map['lat'] as num).toDouble(),
lng: (map['lng'] as num).toDouble(),
updatedAt: AppTime.parse(map['updated_at'] as String),
inPremise: map['in_premise'] as bool? ?? false,
);
}
}
+9
View File
@@ -7,6 +7,9 @@ class NotificationItem {
required this.actorId, required this.actorId,
required this.ticketId, required this.ticketId,
required this.taskId, required this.taskId,
this.leaveId,
this.passSlipId,
required this.itServiceRequestId,
required this.messageId, required this.messageId,
required this.type, required this.type,
required this.createdAt, required this.createdAt,
@@ -18,6 +21,9 @@ class NotificationItem {
final String? actorId; final String? actorId;
final String? ticketId; final String? ticketId;
final String? taskId; final String? taskId;
final String? leaveId;
final String? passSlipId;
final String? itServiceRequestId;
final int? messageId; final int? messageId;
final String type; final String type;
final DateTime createdAt; final DateTime createdAt;
@@ -32,6 +38,9 @@ class NotificationItem {
actorId: map['actor_id'] as String?, actorId: map['actor_id'] as String?,
ticketId: map['ticket_id'] as String?, ticketId: map['ticket_id'] as String?,
taskId: map['task_id'] as String?, taskId: map['task_id'] as String?,
leaveId: map['leave_id'] as String?,
passSlipId: map['pass_slip_id'] as String?,
itServiceRequestId: map['it_service_request_id'] as String?,
messageId: map['message_id'] as int?, messageId: map['message_id'] as int?,
type: map['type'] as String? ?? 'mention', type: map['type'] as String? ?? 'mention',
createdAt: AppTime.parse(map['created_at'] as String), createdAt: AppTime.parse(map['created_at'] as String),
+57
View File
@@ -0,0 +1,57 @@
import '../utils/app_time.dart';
class PassSlip {
PassSlip({
required this.id,
required this.userId,
required this.dutyScheduleId,
required this.reason,
required this.status,
required this.requestedAt,
this.approvedBy,
this.approvedAt,
this.slipStart,
this.slipEnd,
});
final String id;
final String userId;
final String dutyScheduleId;
final String reason;
final String status; // 'pending', 'approved', 'rejected', 'completed'
final DateTime requestedAt;
final String? approvedBy;
final DateTime? approvedAt;
final DateTime? slipStart;
final DateTime? slipEnd;
/// Whether the slip is active (approved but not yet completed).
bool get isActive => status == 'approved' && slipEnd == null;
/// Whether the active slip has exceeded 1 hour.
bool get isExceeded =>
isActive &&
slipStart != null &&
AppTime.now().difference(slipStart!) > const Duration(hours: 1);
factory PassSlip.fromMap(Map<String, dynamic> map) {
return PassSlip(
id: map['id'] as String,
userId: map['user_id'] as String,
dutyScheduleId: map['duty_schedule_id'] as String,
reason: map['reason'] as String,
status: map['status'] as String? ?? 'pending',
requestedAt: AppTime.parse(map['requested_at'] as String),
approvedBy: map['approved_by'] as String?,
approvedAt: map['approved_at'] == null
? null
: AppTime.parse(map['approved_at'] as String),
slipStart: map['slip_start'] == null
? null
: AppTime.parse(map['slip_start'] as String),
slipEnd: map['slip_end'] == null
? null
: AppTime.parse(map['slip_end'] as String),
);
}
}
+24 -1
View File
@@ -1,15 +1,38 @@
class Profile { class Profile {
Profile({required this.id, required this.role, required this.fullName}); Profile({
required this.id,
required this.role,
required this.fullName,
this.religion = 'catholic',
this.allowTracking = false,
this.avatarUrl,
this.facePhotoUrl,
this.faceEnrolledAt,
});
final String id; final String id;
final String role; final String role;
final String fullName; final String fullName;
final String religion;
final bool allowTracking;
final String? avatarUrl;
final String? facePhotoUrl;
final DateTime? faceEnrolledAt;
bool get hasFaceEnrolled => facePhotoUrl != null && faceEnrolledAt != null;
factory Profile.fromMap(Map<String, dynamic> map) { factory Profile.fromMap(Map<String, dynamic> map) {
return Profile( return Profile(
id: map['id'] as String, id: map['id'] as String,
role: map['role'] as String? ?? 'standard', role: map['role'] as String? ?? 'standard',
fullName: map['full_name'] as String? ?? '', fullName: map['full_name'] as String? ?? '',
religion: map['religion'] as String? ?? 'catholic',
allowTracking: map['allow_tracking'] as bool? ?? false,
avatarUrl: map['avatar_url'] as String?,
facePhotoUrl: map['face_photo_url'] as String?,
faceEnrolledAt: map['face_enrolled_at'] == null
? null
: DateTime.tryParse(map['face_enrolled_at'] as String),
); );
} }
} }
+331
View File
@@ -0,0 +1,331 @@
/// Rotation configuration for the duty schedule generator.
///
/// Stored in `app_settings` with key `rotation_config`. Contains:
/// - Ordered list of IT Staff IDs for general rotation
/// - Ordered list of non-Islam IT Staff IDs for Friday AM rotation
/// - Set of excluded IT Staff IDs
/// - Initial AM and PM duty assignments for the next generated schedule
/// - Shift type definitions, role shift type mappings, weekly hour caps
/// - Holiday settings (national / custom)
class RotationConfig {
RotationConfig({
this.rotationOrder = const [],
this.fridayAmOrder = const [],
this.excludedStaffIds = const [],
this.initialAmStaffId,
this.initialPmStaffId,
List<ShiftTypeConfig>? shiftTypes,
Map<String, int>? roleWeeklyHours,
List<Holiday>? holidays,
this.syncPhilippinesHolidays = false,
this.holidaysYear,
}) : shiftTypes = shiftTypes ?? _defaultShiftTypes(),
roleWeeklyHours = roleWeeklyHours ?? {},
holidays = holidays ?? [];
/// Ordered IDs for standard AM/PM rotation.
final List<String> rotationOrder;
/// Ordered IDs for Friday AM duty (non-Islam staff only).
final List<String> fridayAmOrder;
/// Staff IDs excluded from all rotation.
final List<String> excludedStaffIds;
/// The staff member that should take AM duty on the first day of the next
/// generated schedule. `null` means "continue from last week".
final String? initialAmStaffId;
/// The staff member that should take PM duty on the first day of the next
/// generated schedule. `null` means "continue from last week".
final String? initialPmStaffId;
/// Configured shift types with their time definitions and allowed roles.
final List<ShiftTypeConfig> shiftTypes;
/// Weekly hour cap per role (e.g. { 'it_staff': 40 }).
final Map<String, int> roleWeeklyHours;
/// Holidays to render/flag in the schedule. Includes both synced and local.
final List<Holiday> holidays;
/// Whether Philippine national holiday sync is enabled.
final bool syncPhilippinesHolidays;
/// The year used for the last synced holidays. Null means none synced.
final int? holidaysYear;
factory RotationConfig.fromJson(Map<String, dynamic> json) {
return RotationConfig(
rotationOrder: _toStringList(json['rotation_order']),
fridayAmOrder: _toStringList(json['friday_am_order']),
excludedStaffIds: _toStringList(json['excluded_staff_ids']),
initialAmStaffId: json['initial_am_staff_id'] as String?,
initialPmStaffId: json['initial_pm_staff_id'] as String?,
shiftTypes: _toShiftTypes(json['shift_types']),
roleWeeklyHours: _toStringIntMap(json['role_weekly_hours']),
holidays: _toHolidays(json['holidays']),
syncPhilippinesHolidays:
json['sync_philippines_holidays'] as bool? ?? false,
holidaysYear: json['holidays_year'] as int?,
);
}
Map<String, dynamic> toJson() => {
'rotation_order': rotationOrder,
'friday_am_order': fridayAmOrder,
'excluded_staff_ids': excludedStaffIds,
'initial_am_staff_id': initialAmStaffId,
'initial_pm_staff_id': initialPmStaffId,
'shift_types': shiftTypes.map((e) => e.toJson()).toList(),
'role_weekly_hours': roleWeeklyHours,
'holidays': holidays.map((e) => e.toJson()).toList(),
'sync_philippines_holidays': syncPhilippinesHolidays,
'holidays_year': holidaysYear,
};
RotationConfig copyWith({
List<String>? rotationOrder,
List<String>? fridayAmOrder,
List<String>? excludedStaffIds,
String? initialAmStaffId,
String? initialPmStaffId,
bool clearInitialAm = false,
bool clearInitialPm = false,
List<ShiftTypeConfig>? shiftTypes,
Map<String, int>? roleWeeklyHours,
List<Holiday>? holidays,
bool? syncPhilippinesHolidays,
int? holidaysYear,
}) {
return RotationConfig(
rotationOrder: rotationOrder ?? this.rotationOrder,
fridayAmOrder: fridayAmOrder ?? this.fridayAmOrder,
excludedStaffIds: excludedStaffIds ?? this.excludedStaffIds,
initialAmStaffId: clearInitialAm
? null
: (initialAmStaffId ?? this.initialAmStaffId),
initialPmStaffId: clearInitialPm
? null
: (initialPmStaffId ?? this.initialPmStaffId),
shiftTypes: shiftTypes ?? this.shiftTypes,
roleWeeklyHours: roleWeeklyHours ?? this.roleWeeklyHours,
holidays: holidays ?? this.holidays,
syncPhilippinesHolidays:
syncPhilippinesHolidays ?? this.syncPhilippinesHolidays,
holidaysYear: holidaysYear ?? this.holidaysYear,
);
}
static List<ShiftTypeConfig> _defaultShiftTypes() {
return const [
ShiftTypeConfig(
id: 'am',
label: 'AM Duty',
startHour: 7,
startMinute: 0,
durationMinutes: 8 * 60,
allowedRoles: ['it_staff'],
),
ShiftTypeConfig(
id: 'pm',
label: 'PM Duty',
startHour: 15,
startMinute: 0,
durationMinutes: 8 * 60,
allowedRoles: ['it_staff'],
),
ShiftTypeConfig(
id: 'on_call',
label: 'On Call',
startHour: 23,
startMinute: 0,
durationMinutes: 8 * 60,
allowedRoles: ['it_staff'],
),
ShiftTypeConfig(
id: 'on_call_saturday',
label: 'On Call (Saturday)',
startHour: 17,
startMinute: 0,
durationMinutes: 15 * 60,
allowedRoles: ['it_staff'],
),
ShiftTypeConfig(
id: 'on_call_sunday',
label: 'On Call (Sunday)',
startHour: 17,
startMinute: 0,
durationMinutes: 14 * 60,
allowedRoles: ['it_staff'],
),
ShiftTypeConfig(
id: 'normal',
label: 'Normal',
startHour: 8,
startMinute: 0,
durationMinutes: 9 * 60,
allowedRoles: ['admin', 'dispatcher', 'programmer', 'it_staff'],
),
ShiftTypeConfig(
id: 'normal_ramadan_islam',
label: 'Normal (Ramadan - Islam)',
startHour: 8,
startMinute: 0,
durationMinutes: 8 * 60,
allowedRoles: ['admin', 'dispatcher', 'programmer', 'it_staff'],
),
ShiftTypeConfig(
id: 'normal_ramadan_other',
label: 'Normal (Ramadan - Other)',
startHour: 8,
startMinute: 0,
durationMinutes: 9 * 60,
allowedRoles: ['admin', 'dispatcher', 'programmer', 'it_staff'],
),
];
}
static List<String> _toStringList(dynamic value) {
if (value is List) return value.map((e) => e.toString()).toList();
return [];
}
static List<ShiftTypeConfig> _toShiftTypes(dynamic value) {
if (value is List) {
return value
.whereType<Map<String, dynamic>>()
.map(ShiftTypeConfig.fromJson)
.toList();
}
return _defaultShiftTypes();
}
static Map<String, int> _toStringIntMap(dynamic value) {
if (value is Map) {
return value.map((key, val) {
final intVal = val is int
? val
: int.tryParse(val?.toString() ?? '') ?? 0;
return MapEntry(key.toString(), intVal);
});
}
return {};
}
static List<Holiday> _toHolidays(dynamic value) {
if (value is List) {
return value
.whereType<Map<String, dynamic>>()
.map(Holiday.fromJson)
.toList();
}
return [];
}
}
/// A configurable shift type used by the schedule generator.
class ShiftTypeConfig {
const ShiftTypeConfig({
required this.id,
required this.label,
required this.startHour,
required this.startMinute,
required this.durationMinutes,
this.noonBreakMinutes = 60,
this.allowedRoles = const [],
});
/// Unique shift type ID (e.g. "am", "pm", "on_call").
final String id;
/// Human-readable label (e.g. "AM Duty").
final String label;
/// Hour of day (0-23) when this shift begins.
final int startHour;
/// Minute (0-59) when this shift begins.
final int startMinute;
/// Duration in minutes.
final int durationMinutes;
/// Minutes reserved for a noon/meal break during the shift.
final int noonBreakMinutes;
/// Roles that are allowed to be assigned this shift type.
final List<String> allowedRoles;
Duration get duration => Duration(minutes: durationMinutes);
Map<String, dynamic> toJson() => {
'id': id,
'label': label,
'start_hour': startHour,
'start_minute': startMinute,
'duration_minutes': durationMinutes,
'noon_break_minutes': noonBreakMinutes,
'allowed_roles': allowedRoles,
};
factory ShiftTypeConfig.fromJson(Map<String, dynamic> json) {
return ShiftTypeConfig(
id: json['id'] as String,
label: json['label'] as String? ?? json['id'] as String,
startHour: json['start_hour'] is int
? json['start_hour'] as int
: int.tryParse(json['start_hour']?.toString() ?? '') ?? 0,
startMinute: json['start_minute'] is int
? json['start_minute'] as int
: int.tryParse(json['start_minute']?.toString() ?? '') ?? 0,
durationMinutes: json['duration_minutes'] is int
? json['duration_minutes'] as int
: int.tryParse(json['duration_minutes']?.toString() ?? '') ?? 0,
noonBreakMinutes: json['noon_break_minutes'] is int
? json['noon_break_minutes'] as int
: int.tryParse(json['noon_break_minutes']?.toString() ?? '') ?? 60,
allowedRoles: json['allowed_roles'] is List
? (json['allowed_roles'] as List).map((e) => e.toString()).toList()
: [],
);
}
}
/// A holiday used to flag schedule days.
class Holiday {
Holiday({required this.date, required this.name, this.source});
/// Date of the holiday (only date portion is used).
final DateTime date;
/// Holiday name (e.g. "Independence Day").
final String name;
/// Optional source (e.g. "nager") to distinguish synced vs custom.
final String? source;
Map<String, dynamic> toJson() => {
'date': date.toIso8601String(),
'name': name,
'source': source,
};
factory Holiday.fromJson(Map<String, dynamic> json) {
final dateRaw = json['date'];
DateTime date;
if (dateRaw is String) {
date = DateTime.parse(dateRaw);
} else if (dateRaw is DateTime) {
date = dateRaw;
} else {
date = DateTime.now();
}
return Holiday(
date: DateTime(date.year, date.month, date.day),
name: json['name'] as String? ?? 'Holiday',
source: json['source'] as String?,
);
}
}
+76
View File
@@ -25,6 +25,8 @@ class Task {
this.requestTypeOther, this.requestTypeOther,
this.requestCategory, this.requestCategory,
this.actionTaken, this.actionTaken,
this.cancellationReason,
this.cancelledAt,
}); });
final String id; final String id;
@@ -53,6 +55,76 @@ class Task {
// JSON serialized rich text for action taken (Quill Delta JSON encoded) // JSON serialized rich text for action taken (Quill Delta JSON encoded)
final String? actionTaken; final String? actionTaken;
/// Cancellation details when a task was cancelled.
final String? cancellationReason;
final DateTime? cancelledAt;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is Task &&
runtimeType == other.runtimeType &&
id == other.id &&
ticketId == other.ticketId &&
taskNumber == other.taskNumber &&
title == other.title &&
description == other.description &&
officeId == other.officeId &&
status == other.status &&
priority == other.priority &&
queueOrder == other.queueOrder &&
createdAt == other.createdAt &&
creatorId == other.creatorId &&
startedAt == other.startedAt &&
completedAt == other.completedAt &&
requestedBy == other.requestedBy &&
notedBy == other.notedBy &&
receivedBy == other.receivedBy &&
requestType == other.requestType &&
requestTypeOther == other.requestTypeOther &&
requestCategory == other.requestCategory &&
actionTaken == other.actionTaken &&
cancellationReason == other.cancellationReason &&
cancelledAt == other.cancelledAt;
@override
int get hashCode => Object.hash(
id,
ticketId,
taskNumber,
title,
description,
officeId,
status,
priority,
queueOrder,
createdAt,
creatorId,
startedAt,
completedAt,
requestedBy,
notedBy,
receivedBy,
requestType,
requestTypeOther,
requestCategory,
// Object.hash supports max 20 positional args; combine remainder.
Object.hash(actionTaken, cancellationReason, cancelledAt),
);
/// Helper that indicates whether a completed task still has missing
/// metadata such as signatories or action details. The parameter is used
/// by UI to surface a warning icon/banner when a task has been closed but
/// the user skipped filling out all fields.
bool get hasIncompleteDetails {
if (status != 'completed') return false;
bool empty(String? v) => v == null || v.trim().isEmpty;
return empty(requestedBy) ||
empty(notedBy) ||
empty(receivedBy) ||
empty(actionTaken);
}
factory Task.fromMap(Map<String, dynamic> map) { factory Task.fromMap(Map<String, dynamic> map) {
return Task( return Task(
id: map['id'] as String, id: map['id'] as String,
@@ -78,6 +150,10 @@ class Task {
requestedBy: map['requested_by'] as String?, requestedBy: map['requested_by'] as String?,
notedBy: map['noted_by'] as String?, notedBy: map['noted_by'] as String?,
receivedBy: map['received_by'] as String?, receivedBy: map['received_by'] as String?,
cancellationReason: map['cancellation_reason'] as String?,
cancelledAt: map['cancelled_at'] == null
? null
: AppTime.parse(map['cancelled_at'] as String),
actionTaken: (() { actionTaken: (() {
final at = map['action_taken']; final at = map['action_taken'];
if (at == null) return null; if (at == null) return null;
+3
View File
@@ -17,6 +17,7 @@ class Team {
required this.leaderId, required this.leaderId,
required this.officeIds, required this.officeIds,
required this.createdAt, required this.createdAt,
this.color,
}); });
final String id; final String id;
@@ -24,6 +25,7 @@ class Team {
final String leaderId; final String leaderId;
final List<String> officeIds; final List<String> officeIds;
final DateTime createdAt; final DateTime createdAt;
final String? color;
factory Team.fromMap(Map<String, dynamic> map) { factory Team.fromMap(Map<String, dynamic> map) {
return Team( return Team(
@@ -34,6 +36,7 @@ class Team {
(map['office_ids'] as List?)?.map((e) => e.toString()).toList() ?? (map['office_ids'] as List?)?.map((e) => e.toString()).toList() ??
<String>[], <String>[],
createdAt: DateTime.parse(map['created_at'] as String), createdAt: DateTime.parse(map['created_at'] as String),
color: map['color'] as String?,
); );
} }
} }
+30
View File
@@ -25,6 +25,36 @@ class Ticket {
final DateTime? promotedAt; final DateTime? promotedAt;
final DateTime? closedAt; final DateTime? closedAt;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is Ticket &&
runtimeType == other.runtimeType &&
id == other.id &&
subject == other.subject &&
description == other.description &&
officeId == other.officeId &&
status == other.status &&
createdAt == other.createdAt &&
creatorId == other.creatorId &&
respondedAt == other.respondedAt &&
promotedAt == other.promotedAt &&
closedAt == other.closedAt;
@override
int get hashCode => Object.hash(
id,
subject,
description,
officeId,
status,
createdAt,
creatorId,
respondedAt,
promotedAt,
closedAt,
);
factory Ticket.fromMap(Map<String, dynamic> map) { factory Ticket.fromMap(Map<String, dynamic> map) {
return Ticket( return Ticket(
id: map['id'] as String, id: map['id'] as String,
+54
View File
@@ -0,0 +1,54 @@
import '../utils/app_time.dart';
/// A cross-device face verification session.
///
/// Created on web when no camera is detected. The web client generates a QR
/// code containing the session ID. The mobile client scans the QR, performs
/// liveness detection, uploads the photo, and marks the session completed.
class VerificationSession {
VerificationSession({
required this.id,
required this.userId,
required this.type,
this.contextId,
this.status = 'pending',
this.imageUrl,
required this.createdAt,
required this.expiresAt,
});
final String id;
final String userId;
final String type; // 'enrollment' or 'verification'
final String? contextId; // e.g. attendance_log_id
final String status; // 'pending', 'completed', 'expired'
final String? imageUrl;
final DateTime createdAt;
final DateTime expiresAt;
bool get isPending => status == 'pending';
bool get isCompleted => status == 'completed';
bool get isExpired =>
status == 'expired' || DateTime.now().toUtc().isAfter(expiresAt);
factory VerificationSession.fromMap(Map<String, dynamic> map) {
return VerificationSession(
id: map['id'] as String,
userId: map['user_id'] as String,
type: map['type'] as String,
contextId: map['context_id'] as String?,
status: (map['status'] as String?) ?? 'pending',
imageUrl: map['image_url'] as String?,
createdAt: AppTime.parse(map['created_at'] as String),
expiresAt: AppTime.parse(map['expires_at'] as String),
);
}
Map<String, dynamic> toJson() {
return {
'user_id': userId,
'type': type,
if (contextId != null) 'context_id': contextId,
};
}
}
+2 -1
View File
@@ -61,10 +61,11 @@ class AdminUserController {
required String userId, required String userId,
required String fullName, required String fullName,
required String role, required String role,
String religion = 'catholic',
}) async { }) async {
await _client await _client
.from('profiles') .from('profiles')
.update({'full_name': fullName, 'role': role}) .update({'full_name': fullName, 'role': role, 'religion': religion})
.eq('id', userId); .eq('id', userId);
} }
+170
View File
@@ -0,0 +1,170 @@
import 'dart:typed_data';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../models/attendance_log.dart';
import '../utils/app_time.dart';
import 'profile_provider.dart';
import 'reports_provider.dart';
import 'supabase_provider.dart';
import 'stream_recovery.dart';
import 'realtime_controller.dart';
/// Date range for attendance logbook, defaults to "Last 7 Days".
final attendanceDateRangeProvider = StateProvider<ReportDateRange>((ref) {
final now = AppTime.now();
final today = DateTime(now.year, now.month, now.day);
return ReportDateRange(
start: today,
end: today.add(const Duration(days: 1)),
label: 'Today',
);
});
/// Filter for logbook users (multi-select). If empty, all users are shown.
final attendanceUserFilterProvider = StateProvider<List<String>>((ref) => []);
/// All visible attendance logs (own for standard, all for admin/dispatcher/it_staff).
final attendanceLogsProvider = StreamProvider<List<AttendanceLog>>((ref) {
final client = ref.watch(supabaseClientProvider);
// Use .select() so the stream is only recreated when the user id or role
// actually changes (not on avatar/name edits, etc.).
final profileId = ref.watch(
currentProfileProvider.select((p) => p.valueOrNull?.id),
);
final profileRole = ref.watch(
currentProfileProvider.select((p) => p.valueOrNull?.role),
);
if (profileId == null) return Stream.value(const <AttendanceLog>[]);
final hasFullAccess =
profileRole == 'admin' ||
profileRole == 'programmer' ||
profileRole == 'dispatcher' ||
profileRole == 'it_staff';
final wrapper = StreamRecoveryWrapper<AttendanceLog>(
stream: hasFullAccess
? client
.from('attendance_logs')
.stream(primaryKey: ['id'])
.order('check_in_at', ascending: false)
: client
.from('attendance_logs')
.stream(primaryKey: ['id'])
.eq('user_id', profileId)
.order('check_in_at', ascending: false),
onPollData: () async {
final query = client.from('attendance_logs').select();
final data = hasFullAccess
? await query.order('check_in_at', ascending: false)
: await query
.eq('user_id', profileId)
.order('check_in_at', ascending: false);
return data.map(AttendanceLog.fromMap).toList();
},
fromMap: AttendanceLog.fromMap,
channelName: 'attendance_logs',
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
);
ref.onDispose(wrapper.dispose);
return wrapper.stream.map((result) => result.data);
});
final attendanceControllerProvider = Provider<AttendanceController>((ref) {
final client = ref.watch(supabaseClientProvider);
return AttendanceController(client);
});
class AttendanceController {
AttendanceController(this._client);
final SupabaseClient _client;
/// Check in to a duty schedule. Returns the attendance log ID.
Future<String?> checkIn({
required String dutyScheduleId,
required double lat,
required double lng,
}) async {
final data = await _client.rpc(
'attendance_check_in',
params: {'p_duty_id': dutyScheduleId, 'p_lat': lat, 'p_lng': lng},
);
return data as String?;
}
/// Check out from an attendance log.
Future<void> checkOut({
required String attendanceId,
required double lat,
required double lng,
String? justification,
}) async {
await _client.rpc(
'attendance_check_out',
params: {
'p_attendance_id': attendanceId,
'p_lat': lat,
'p_lng': lng,
'p_justification': justification,
},
);
}
/// Overtime check-in (no pre-existing schedule required).
/// Creates an overtime duty schedule + attendance log in one RPC call.
Future<String?> overtimeCheckIn({
required double lat,
required double lng,
String? justification,
}) async {
final data = await _client.rpc(
'overtime_check_in',
params: {'p_lat': lat, 'p_lng': lng, 'p_justification': justification},
);
return data as String?;
}
/// Upload a verification selfie and update the attendance log.
Future<void> uploadVerification({
required String attendanceId,
required Uint8List bytes,
required String fileName,
required String status, // 'verified', 'unverified'
bool isCheckOut = false,
}) async {
final userId = _client.auth.currentUser!.id;
final ext = fileName.split('.').last.toLowerCase();
final prefix = isCheckOut ? 'checkout' : 'checkin';
final path = '$userId/${prefix}_$attendanceId.$ext';
await _client.storage
.from('attendance-verification')
.uploadBinary(
path,
bytes,
fileOptions: const FileOptions(upsert: true),
);
final url = _client.storage
.from('attendance-verification')
.getPublicUrl(path);
final column = isCheckOut
? 'check_out_verification_photo_url'
: 'check_in_verification_photo_url';
await _client
.from('attendance_logs')
.update({'verification_status': status, column: url})
.eq('id', attendanceId);
}
/// Mark an attendance log as skipped verification.
Future<void> skipVerification(String attendanceId) async {
await _client
.from('attendance_logs')
.update({'verification_status': 'skipped'})
.eq('id', attendanceId);
}
}
+19
View File
@@ -2,6 +2,8 @@ import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'notifications_provider.dart';
import 'supabase_provider.dart'; import 'supabase_provider.dart';
@@ -64,6 +66,23 @@ class AuthController {
} }
Future<void> signOut() { Future<void> signOut() {
// Attempt to unregister this device's FCM token before signing out.
return _doSignOut();
}
Future<void> _doSignOut() async {
try {
final userId = _client.auth.currentUser?.id;
if (userId != null) {
try {
final token = await FirebaseMessaging.instance.getToken();
if (token != null) {
final ctrl = NotificationsController(_client);
await ctrl.unregisterFcmToken(token);
}
} catch (_) {}
}
} catch (_) {}
return _client.auth.signOut(); return _client.auth.signOut();
} }
} }
+61
View File
@@ -0,0 +1,61 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../models/chat_message.dart';
import 'supabase_provider.dart';
import 'stream_recovery.dart';
import 'realtime_controller.dart';
/// Real-time chat messages for a swap request thread.
final chatMessagesProvider = StreamProvider.family<List<ChatMessage>, String>((
ref,
threadId,
) {
final client = ref.watch(supabaseClientProvider);
final wrapper = StreamRecoveryWrapper<ChatMessage>(
stream: client
.from('chat_messages')
.stream(primaryKey: ['id'])
.eq('thread_id', threadId)
.order('created_at'),
onPollData: () async {
final data = await client
.from('chat_messages')
.select()
.eq('thread_id', threadId)
.order('created_at');
return data.map(ChatMessage.fromMap).toList();
},
fromMap: ChatMessage.fromMap,
channelName: 'chat_messages_$threadId',
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
);
ref.onDispose(wrapper.dispose);
return wrapper.stream.map((result) => result.data);
});
final chatControllerProvider = Provider<ChatController>((ref) {
final client = ref.watch(supabaseClientProvider);
return ChatController(client);
});
class ChatController {
ChatController(this._client);
final SupabaseClient _client;
Future<void> sendMessage({
required String threadId,
required String body,
}) async {
final userId = _client.auth.currentUser?.id;
if (userId == null) throw Exception('Not authenticated');
await _client.from('chat_messages').insert({
'thread_id': threadId,
'sender_id': userId,
'body': body,
});
}
}
@@ -0,0 +1,35 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// Debug-only settings for development and testing.
/// Only functional in debug builds (kDebugMode).
class DebugSettings {
final bool bypassGeofence;
const DebugSettings({this.bypassGeofence = false});
DebugSettings copyWith({bool? bypassGeofence}) {
return DebugSettings(bypassGeofence: bypassGeofence ?? this.bypassGeofence);
}
}
class DebugSettingsNotifier extends StateNotifier<DebugSettings> {
DebugSettingsNotifier() : super(const DebugSettings());
void toggleGeofenceBypass() {
if (kDebugMode) {
state = state.copyWith(bypassGeofence: !state.bypassGeofence);
}
}
void setGeofenceBypass(bool value) {
if (kDebugMode) {
state = state.copyWith(bypassGeofence: value);
}
}
}
final debugSettingsProvider =
StateNotifierProvider<DebugSettingsNotifier, DebugSettings>(
(ref) => DebugSettingsNotifier(),
);
@@ -0,0 +1,653 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:flutter/material.dart';
import '../models/it_service_request.dart';
import '../models/it_service_request_assignment.dart';
import '../models/it_service_request_activity_log.dart';
import '../models/it_service_request_action.dart';
import 'profile_provider.dart';
import 'supabase_provider.dart';
import 'user_offices_provider.dart';
import 'stream_recovery.dart';
import 'realtime_controller.dart';
// ---------------------------------------------------------------------------
// Query parameters
// ---------------------------------------------------------------------------
class ItServiceRequestQuery {
const ItServiceRequestQuery({
this.offset = 0,
this.limit = 50,
this.searchQuery = '',
this.officeId,
this.status,
this.dateRange,
});
final int offset;
final int limit;
final String searchQuery;
final String? officeId;
final String? status;
final DateTimeRange? dateRange;
ItServiceRequestQuery copyWith({
int? offset,
int? limit,
String? searchQuery,
String? officeId,
String? status,
DateTimeRange? dateRange,
}) {
return ItServiceRequestQuery(
offset: offset ?? this.offset,
limit: limit ?? this.limit,
searchQuery: searchQuery ?? this.searchQuery,
officeId: officeId ?? this.officeId,
status: status ?? this.status,
dateRange: dateRange ?? this.dateRange,
);
}
}
final itServiceRequestQueryProvider = StateProvider<ItServiceRequestQuery>(
(ref) => const ItServiceRequestQuery(),
);
// ---------------------------------------------------------------------------
// Stream providers
// ---------------------------------------------------------------------------
final itServiceRequestsProvider = StreamProvider<List<ItServiceRequest>>((ref) {
final userId = ref.watch(currentUserIdProvider);
if (userId == null) return const Stream.empty();
final client = ref.watch(supabaseClientProvider);
final profile = ref.watch(currentProfileProvider).valueOrNull;
final userOfficesAsync = ref.watch(userOfficesProvider);
final wrapper = StreamRecoveryWrapper<ItServiceRequest>(
stream: client
.from('it_service_requests')
.stream(primaryKey: ['id'])
.order('created_at', ascending: false),
onPollData: () async {
final data = await client
.from('it_service_requests')
.select()
.order('created_at', ascending: false);
return data.map(ItServiceRequest.fromMap).toList();
},
fromMap: ItServiceRequest.fromMap,
channelName: 'it_service_requests',
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
);
ref.onDispose(wrapper.dispose);
return wrapper.stream.map((result) {
var items = result.data;
// Standard users see only requests from their offices or created by them
if (profile != null && profile.role == 'standard') {
final officeIds = (userOfficesAsync.valueOrNull ?? [])
.where((a) => a.userId == userId)
.map((a) => a.officeId)
.toSet();
items = items
.where(
(r) =>
r.creatorId == userId ||
(r.officeId != null && officeIds.contains(r.officeId)),
)
.toList();
}
return items;
});
});
final itServiceRequestByIdProvider = Provider.family<ItServiceRequest?, String>(
(ref, id) {
final requests = ref.watch(itServiceRequestsProvider).valueOrNull;
if (requests == null) return null;
try {
return requests.firstWhere((r) => r.id == id);
} catch (_) {
return null;
}
},
);
final itServiceRequestAssignmentsProvider =
StreamProvider<List<ItServiceRequestAssignment>>((ref) {
final userId = ref.watch(currentUserIdProvider);
if (userId == null) return const Stream.empty();
final client = ref.watch(supabaseClientProvider);
final wrapper = StreamRecoveryWrapper<ItServiceRequestAssignment>(
stream: client
.from('it_service_request_assignments')
.stream(primaryKey: ['id'])
.order('created_at', ascending: false),
onPollData: () async {
final data = await client
.from('it_service_request_assignments')
.select()
.order('created_at', ascending: false);
return data.map(ItServiceRequestAssignment.fromMap).toList();
},
fromMap: ItServiceRequestAssignment.fromMap,
channelName: 'it_service_request_assignments',
onStatusChanged: ref
.read(realtimeControllerProvider)
.handleChannelStatus,
);
ref.onDispose(wrapper.dispose);
return wrapper.stream.map((result) => result.data);
});
final itServiceRequestActivityLogsProvider =
StreamProvider.family<List<ItServiceRequestActivityLog>, String>((
ref,
requestId,
) {
final client = ref.watch(supabaseClientProvider);
final wrapper = StreamRecoveryWrapper<ItServiceRequestActivityLog>(
stream: client
.from('it_service_request_activity_logs')
.stream(primaryKey: ['id'])
.eq('request_id', requestId)
.order('created_at', ascending: false),
onPollData: () async {
final data = await client
.from('it_service_request_activity_logs')
.select()
.eq('request_id', requestId)
.order('created_at', ascending: false);
return data.map(ItServiceRequestActivityLog.fromMap).toList();
},
fromMap: ItServiceRequestActivityLog.fromMap,
channelName: 'isr_activity_logs_$requestId',
onStatusChanged: ref
.read(realtimeControllerProvider)
.handleChannelStatus,
);
ref.onDispose(wrapper.dispose);
return wrapper.stream.map((result) => result.data);
});
final itServiceRequestActionsProvider =
StreamProvider.family<List<ItServiceRequestAction>, String>((
ref,
requestId,
) {
final client = ref.watch(supabaseClientProvider);
final wrapper = StreamRecoveryWrapper<ItServiceRequestAction>(
stream: client
.from('it_service_request_actions')
.stream(primaryKey: ['id'])
.eq('request_id', requestId)
.order('created_at', ascending: false),
onPollData: () async {
final data = await client
.from('it_service_request_actions')
.select()
.eq('request_id', requestId)
.order('created_at', ascending: false);
return data.map(ItServiceRequestAction.fromMap).toList();
},
fromMap: ItServiceRequestAction.fromMap,
channelName: 'isr_actions_$requestId',
onStatusChanged: ref
.read(realtimeControllerProvider)
.handleChannelStatus,
);
ref.onDispose(wrapper.dispose);
return wrapper.stream.map((result) => result.data);
});
// ---------------------------------------------------------------------------
// Controller
// ---------------------------------------------------------------------------
final itServiceRequestControllerProvider = Provider<ItServiceRequestController>(
(ref) {
final client = ref.watch(supabaseClientProvider);
return ItServiceRequestController(client);
},
);
class ItServiceRequestController {
ItServiceRequestController(this._client);
final SupabaseClient _client;
/// Creates a new IT Service Request with auto-generated number.
Future<Map<String, dynamic>> createRequest({
required String eventName,
required List<String> services,
String? servicesOther,
String? officeId,
String? requestedBy,
String? requestedByUserId,
String status = 'draft',
}) async {
final userId = _client.auth.currentUser?.id;
if (userId == null) throw Exception('Not authenticated');
try {
final result = await _client.rpc(
'insert_it_service_request_with_number',
params: {
'p_event_name': eventName,
'p_services': services,
'p_creator_id': userId,
'p_office_id': officeId,
'p_requested_by': requestedBy,
'p_requested_by_user_id': requestedByUserId,
'p_status': status,
},
);
final row = result is List
? (result.first as Map<String, dynamic>)
: result;
final requestId = row['id'] as String;
if (servicesOther != null && servicesOther.isNotEmpty) {
await _client
.from('it_service_requests')
.update({'services_other': servicesOther})
.eq('id', requestId);
}
// Activity log
await _client.from('it_service_request_activity_logs').insert({
'request_id': requestId,
'actor_id': userId,
'action_type': 'created',
});
return {'id': requestId, 'request_number': row['request_number']};
} catch (e) {
debugPrint('createRequest error: $e');
rethrow;
}
}
/// Updates IT Service Request fields.
Future<void> updateRequest({
required String requestId,
String? eventName,
List<String>? services,
String? servicesOther,
String? eventDetails,
DateTime? eventDate,
DateTime? eventEndDate,
DateTime? dryRunDate,
DateTime? dryRunEndDate,
String? contactPerson,
String? contactNumber,
String? remarks,
String? officeId,
String? requestedBy,
String? requestedByUserId,
String? approvedBy,
String? approvedByUserId,
bool? outsidePremiseAllowed,
DateTime? dateTimeReceived,
DateTime? dateTimeChecked,
}) async {
final updates = <String, dynamic>{};
if (eventName != null) updates['event_name'] = eventName;
if (services != null) updates['services'] = services;
if (servicesOther != null) updates['services_other'] = servicesOther;
if (eventDetails != null) updates['event_details'] = eventDetails;
if (eventDate != null) updates['event_date'] = eventDate.toIso8601String();
if (eventEndDate != null) {
updates['event_end_date'] = eventEndDate.toIso8601String();
}
if (dryRunDate != null) {
updates['dry_run_date'] = dryRunDate.toIso8601String();
}
if (dryRunEndDate != null) {
updates['dry_run_end_date'] = dryRunEndDate.toIso8601String();
}
if (contactPerson != null) updates['contact_person'] = contactPerson;
if (contactNumber != null) updates['contact_number'] = contactNumber;
if (remarks != null) updates['remarks'] = remarks;
if (officeId != null) updates['office_id'] = officeId;
if (requestedBy != null) updates['requested_by'] = requestedBy;
if (requestedByUserId != null) {
updates['requested_by_user_id'] = requestedByUserId;
}
if (approvedBy != null) updates['approved_by'] = approvedBy;
if (approvedByUserId != null) {
updates['approved_by_user_id'] = approvedByUserId;
}
if (outsidePremiseAllowed != null) {
updates['outside_premise_allowed'] = outsidePremiseAllowed;
}
if (dateTimeReceived != null) {
updates['date_time_received'] = dateTimeReceived.toIso8601String();
}
if (dateTimeChecked != null) {
updates['date_time_checked'] = dateTimeChecked.toIso8601String();
}
if (updates.isEmpty) return;
await _client
.from('it_service_requests')
.update(updates)
.eq('id', requestId);
// Log updated fields
final userId = _client.auth.currentUser?.id;
await _client.from('it_service_request_activity_logs').insert({
'request_id': requestId,
'actor_id': userId,
'action_type': 'updated',
'meta': {'fields': updates.keys.toList()},
});
}
/// Update only the status of an IT Service Request.
Future<void> updateStatus({
required String requestId,
required String status,
String? cancellationReason,
}) async {
final userId = _client.auth.currentUser?.id;
final updates = <String, dynamic>{'status': status};
if (status == 'scheduled') {
updates['approved_at'] = DateTime.now().toUtc().toIso8601String();
updates['approved_by_user_id'] = userId;
}
if (status == 'completed') {
updates['completed_at'] = DateTime.now().toUtc().toIso8601String();
}
if (status == 'cancelled') {
updates['cancelled_at'] = DateTime.now().toUtc().toIso8601String();
if (cancellationReason != null) {
updates['cancellation_reason'] = cancellationReason;
}
}
await _client
.from('it_service_requests')
.update(updates)
.eq('id', requestId);
await _client.from('it_service_request_activity_logs').insert({
'request_id': requestId,
'actor_id': userId,
'action_type': 'status_changed',
'meta': {'status': status},
});
}
/// Approve a request (admin only). Sets status to 'scheduled'.
Future<void> approveRequest({
required String requestId,
required String approverName,
}) async {
final userId = _client.auth.currentUser?.id;
await _client
.from('it_service_requests')
.update({
'status': 'scheduled',
'approved_by': approverName,
'approved_by_user_id': userId,
'approved_at': DateTime.now().toUtc().toIso8601String(),
})
.eq('id', requestId);
await _client.from('it_service_request_activity_logs').insert({
'request_id': requestId,
'actor_id': userId,
'action_type': 'approved',
});
}
// -----------------------------------------------------------------------
// Assignment management
// -----------------------------------------------------------------------
Future<void> assignStaff({
required String requestId,
required List<String> userIds,
}) async {
final actorId = _client.auth.currentUser?.id;
final rows = userIds
.map((uid) => {'request_id': requestId, 'user_id': uid})
.toList();
await _client
.from('it_service_request_assignments')
.upsert(rows, onConflict: 'request_id,user_id');
await _client.from('it_service_request_activity_logs').insert({
'request_id': requestId,
'actor_id': actorId,
'action_type': 'assigned',
'meta': {'user_ids': userIds},
});
}
Future<void> unassignStaff({
required String requestId,
required String userId,
}) async {
final actorId = _client.auth.currentUser?.id;
await _client
.from('it_service_request_assignments')
.delete()
.eq('request_id', requestId)
.eq('user_id', userId);
await _client.from('it_service_request_activity_logs').insert({
'request_id': requestId,
'actor_id': actorId,
'action_type': 'unassigned',
'meta': {'user_id': userId},
});
}
// -----------------------------------------------------------------------
// Action Taken
// -----------------------------------------------------------------------
Future<String> createOrUpdateAction({
required String requestId,
required String actionTaken,
}) async {
final userId = _client.auth.currentUser?.id;
if (userId == null) throw Exception('Not authenticated');
// Check if action already exists for this user
final existing = await _client
.from('it_service_request_actions')
.select('id')
.eq('request_id', requestId)
.eq('user_id', userId)
.maybeSingle();
if (existing != null) {
await _client
.from('it_service_request_actions')
.update({'action_taken': actionTaken})
.eq('id', existing['id']);
return existing['id'] as String;
} else {
final result = await _client
.from('it_service_request_actions')
.insert({
'request_id': requestId,
'user_id': userId,
'action_taken': actionTaken,
})
.select('id')
.single();
return result['id'] as String;
}
}
// -----------------------------------------------------------------------
// Evidence Attachments
// -----------------------------------------------------------------------
Future<String> uploadEvidence({
required String requestId,
required String actionId,
required String fileName,
required Uint8List bytes,
DateTime? takenAt,
}) async {
final userId = _client.auth.currentUser?.id;
if (userId == null) throw Exception('Not authenticated');
final path = '$requestId/evidence/$fileName';
if (kIsWeb) {
await _client.storage
.from('it_service_attachments')
.uploadBinary(
path,
bytes,
fileOptions: const FileOptions(upsert: true),
);
} else {
final tmpDir = Directory.systemTemp;
final tmpFile = File('${tmpDir.path}/$fileName');
await tmpFile.writeAsBytes(bytes);
await _client.storage
.from('it_service_attachments')
.upload(path, tmpFile, fileOptions: const FileOptions(upsert: true));
try {
await tmpFile.delete();
} catch (_) {}
}
await _client.from('it_service_request_evidence').insert({
'request_id': requestId,
'action_id': actionId,
'user_id': userId,
'file_path': path,
'file_name': fileName,
'taken_at': takenAt?.toIso8601String(),
});
return _client.storage.from('it_service_attachments').getPublicUrl(path);
}
Future<void> deleteEvidence({required String evidenceId}) async {
// Get path first
final row = await _client
.from('it_service_request_evidence')
.select('file_path')
.eq('id', evidenceId)
.single();
final path = row['file_path'] as String;
await _client.storage.from('it_service_attachments').remove([path]);
await _client
.from('it_service_request_evidence')
.delete()
.eq('id', evidenceId);
}
// -----------------------------------------------------------------------
// File Attachments (event files, max 25MB)
// -----------------------------------------------------------------------
Future<String> uploadAttachment({
required String requestId,
required String fileName,
required Uint8List bytes,
}) async {
if (bytes.length > 25 * 1024 * 1024) {
throw Exception('File size exceeds 25MB limit');
}
final path = '$requestId/attachments/$fileName';
if (kIsWeb) {
await _client.storage
.from('it_service_attachments')
.uploadBinary(
path,
bytes,
fileOptions: const FileOptions(upsert: true),
);
} else {
final tmpDir = Directory.systemTemp;
final tmpFile = File('${tmpDir.path}/$fileName');
await tmpFile.writeAsBytes(bytes);
await _client.storage
.from('it_service_attachments')
.upload(path, tmpFile, fileOptions: const FileOptions(upsert: true));
try {
await tmpFile.delete();
} catch (_) {}
}
return _client.storage.from('it_service_attachments').getPublicUrl(path);
}
Future<List<Map<String, dynamic>>> listAttachments(String requestId) async {
try {
final files = await _client.storage
.from('it_service_attachments')
.list(path: '$requestId/attachments');
return files
.map(
(f) => {
'name': f.name,
'url': _client.storage
.from('it_service_attachments')
.getPublicUrl('$requestId/attachments/${f.name}'),
},
)
.toList();
} catch (_) {
return [];
}
}
Future<void> deleteAttachment({
required String requestId,
required String fileName,
}) async {
final path = '$requestId/attachments/$fileName';
await _client.storage.from('it_service_attachments').remove([path]);
}
/// List evidence attachments for a request from the database.
Future<List<Map<String, dynamic>>> listEvidence(String requestId) async {
final rows = await _client
.from('it_service_request_evidence')
.select()
.eq('request_id', requestId)
.order('created_at', ascending: false);
return rows
.map(
(r) => {
'id': r['id'],
'file_name': r['file_name'],
'file_path': r['file_path'],
'taken_at': r['taken_at'],
'url': _client.storage
.from('it_service_attachments')
.getPublicUrl(r['file_path'] as String),
},
)
.toList();
}
}
+295
View File
@@ -0,0 +1,295 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../models/leave_of_absence.dart';
import 'profile_provider.dart';
import 'supabase_provider.dart';
import 'stream_recovery.dart';
import 'realtime_controller.dart';
/// All visible leaves (own for standard, all for admin/dispatcher/it_staff).
///
/// Consumers should **not** treat every record as an active absence; the UI
/// layers (dashboard, logbook) explicitly filter to `status == 'approved'` and
/// verify the leave overlaps the current time. This prevents rejected or
/// pending applications from inadvertently influencing schedules or status
/// computations.
final leavesProvider = StreamProvider<List<LeaveOfAbsence>>((ref) {
final client = ref.watch(supabaseClientProvider);
final profileAsync = ref.watch(currentProfileProvider);
final profile = profileAsync.valueOrNull;
if (profile == null) return Stream.value(const <LeaveOfAbsence>[]);
final hasFullAccess =
profile.role == 'admin' ||
profile.role == 'programmer' ||
profile.role == 'dispatcher' ||
profile.role == 'it_staff';
final wrapper = StreamRecoveryWrapper<LeaveOfAbsence>(
stream: hasFullAccess
? client
.from('leave_of_absence')
.stream(primaryKey: ['id'])
.order('start_time', ascending: false)
: client
.from('leave_of_absence')
.stream(primaryKey: ['id'])
.eq('user_id', profile.id)
.order('start_time', ascending: false),
onPollData: () async {
final query = client.from('leave_of_absence').select();
final data = hasFullAccess
? await query.order('start_time', ascending: false)
: await query
.eq('user_id', profile.id)
.order('start_time', ascending: false);
return data.map(LeaveOfAbsence.fromMap).toList();
},
fromMap: LeaveOfAbsence.fromMap,
channelName: 'leave_of_absence',
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
);
ref.onDispose(wrapper.dispose);
return wrapper.stream.map((result) => result.data);
});
final leaveControllerProvider = Provider<LeaveController>((ref) {
final client = ref.watch(supabaseClientProvider);
return LeaveController(client);
});
class LeaveController {
LeaveController(this._client);
final SupabaseClient _client;
/// File a leave of absence for the current user.
/// Caller controls auto-approval based on role policy.
Future<void> fileLeave({
required String leaveType,
required String justification,
required DateTime startTime,
required DateTime endTime,
required bool autoApprove,
}) async {
final uid = _client.auth.currentUser!.id;
final payload = {
'user_id': uid,
'leave_type': leaveType,
'justification': justification,
'start_time': startTime.toIso8601String(),
'end_time': endTime.toIso8601String(),
'status': autoApprove ? 'approved' : 'pending',
'filed_by': uid,
};
final insertedRaw = await _client
.from('leave_of_absence')
.insert(payload)
.select()
.maybeSingle();
final Map<String, dynamic>? inserted = insertedRaw is Map<String, dynamic>
? insertedRaw
: null;
// If this was filed as pending, notify admins for approval
final status = payload['status'] as String;
if (status != 'pending') return;
try {
final adminIds = await _fetchRoleUserIds(
roles: const ['admin'],
excludeUserId: uid,
);
if (adminIds.isEmpty) return;
// Resolve actor display name for nicer push text
String actorName = 'Someone';
try {
final p = await _client
.from('profiles')
.select('full_name,display_name,name')
.eq('id', uid)
.maybeSingle();
if (p != null) {
if (p['full_name'] != null) {
actorName = p['full_name'].toString();
} else if (p['display_name'] != null) {
actorName = p['display_name'].toString();
} else if (p['name'] != null) {
actorName = p['name'].toString();
}
}
} catch (_) {}
final leaveId = (inserted ?? <String, dynamic>{})['id']?.toString() ?? '';
final title = 'Leave Filed for Approval';
final body = '$actorName filed a leave request that requires approval.';
final notificationId = (inserted ?? <String, dynamic>{})['id']
?.toString();
final dataPayload = <String, dynamic>{
'type': 'leave_filed',
'leave_id': leaveId,
...?(notificationId != null
? {'notification_id': notificationId}
: null),
};
await _client
.from('notifications')
.insert(
adminIds
.map(
(userId) => {
'user_id': userId,
'actor_id': uid,
'type': 'leave_filed',
'leave_id': leaveId,
},
)
.toList(),
);
final res = await _client.functions.invoke(
'send_fcm',
body: {
'user_ids': adminIds,
'title': title,
'body': body,
'data': dataPayload,
},
);
debugPrint('leave filing send_fcm result: $res');
} catch (e) {
debugPrint('leave filing send_fcm error: $e');
// Non-fatal: keep leave filing working even if send_fcm fails
}
}
/// Approve a leave request.
Future<void> approveLeave(String leaveId) async {
final userId = _client.auth.currentUser?.id;
if (userId == null) throw Exception('Not authenticated');
// Update status first; then notify the requester.
await _client
.from('leave_of_absence')
.update({'status': 'approved'})
.eq('id', leaveId);
// Notify requestor
await _notifyRequester(leaveId: leaveId, actorId: userId, approved: true);
}
/// Reject a leave request.
Future<void> rejectLeave(String leaveId) async {
final userId = _client.auth.currentUser?.id;
if (userId == null) throw Exception('Not authenticated');
await _client
.from('leave_of_absence')
.update({'status': 'rejected'})
.eq('id', leaveId);
// Notify requestor
await _notifyRequester(leaveId: leaveId, actorId: userId, approved: false);
}
Future<void> _notifyRequester({
required String leaveId,
required String actorId,
required bool approved,
}) async {
try {
final row = await _client
.from('leave_of_absence')
.select('user_id')
.eq('id', leaveId)
.maybeSingle();
// ignore: unnecessary_cast
final rowMap = row as Map<String, dynamic>?;
final userId = rowMap?['user_id']?.toString();
if (userId == null || userId.isEmpty) return;
String actorName = 'Someone';
try {
final p = await _client
.from('profiles')
.select('full_name,display_name,name')
.eq('id', actorId)
.maybeSingle();
if (p != null) {
if (p['full_name'] != null) {
actorName = p['full_name'].toString();
} else if (p['display_name'] != null) {
actorName = p['display_name'].toString();
} else if (p['name'] != null) {
actorName = p['name'].toString();
}
}
} catch (_) {}
final title = approved ? 'Leave Approved' : 'Leave Rejected';
final body = approved
? '$actorName approved your leave request.'
: '$actorName rejected your leave request.';
final dataPayload = <String, dynamic>{
'type': approved ? 'leave_approved' : 'leave_rejected',
'leave_id': leaveId,
};
await _client.from('notifications').insert({
'user_id': userId,
'actor_id': actorId,
'type': approved ? 'leave_approved' : 'leave_rejected',
'leave_id': leaveId,
});
await _client.functions.invoke(
'send_fcm',
body: {
'user_ids': [userId],
'title': title,
'body': body,
'data': dataPayload,
},
);
} catch (_) {
// non-fatal
}
}
/// Cancel an approved leave.
Future<void> cancelLeave(String leaveId) async {
await _client
.from('leave_of_absence')
.update({'status': 'cancelled'})
.eq('id', leaveId);
}
Future<List<String>> _fetchRoleUserIds({
required List<String> roles,
required String? excludeUserId,
}) async {
try {
final data = await _client
.from('profiles')
.select('id, role')
.inFilter('role', roles);
final rows = data as List<dynamic>;
final ids = rows
.map((row) => row['id'] as String?)
.whereType<String>()
.where((id) => id.isNotEmpty && id != excludeUserId)
.toList();
return ids;
} catch (_) {
return [];
}
}
}
@@ -0,0 +1,7 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
typedef PendingNavigation = ({String type, String id})?;
final pendingNotificationNavigationProvider = StateProvider<PendingNavigation>(
(ref) => null,
);
+194 -19
View File
@@ -1,9 +1,13 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:supabase_flutter/supabase_flutter.dart';
import '../utils/device_id.dart';
import '../models/notification_item.dart'; import '../models/notification_item.dart';
import 'profile_provider.dart'; import 'profile_provider.dart';
import 'supabase_provider.dart'; import 'supabase_provider.dart';
import 'stream_recovery.dart';
import 'realtime_controller.dart';
import '../utils/app_time.dart'; import '../utils/app_time.dart';
final notificationsProvider = StreamProvider<List<NotificationItem>>((ref) { final notificationsProvider = StreamProvider<List<NotificationItem>>((ref) {
@@ -12,12 +16,28 @@ final notificationsProvider = StreamProvider<List<NotificationItem>>((ref) {
return const Stream.empty(); return const Stream.empty();
} }
final client = ref.watch(supabaseClientProvider); final client = ref.watch(supabaseClientProvider);
return client
final wrapper = StreamRecoveryWrapper<NotificationItem>(
stream: client
.from('notifications') .from('notifications')
.stream(primaryKey: ['id']) .stream(primaryKey: ['id'])
.eq('user_id', userId) .eq('user_id', userId)
.order('created_at', ascending: false) .order('created_at', ascending: false),
.map((rows) => rows.map(NotificationItem.fromMap).toList()); onPollData: () async {
final data = await client
.from('notifications')
.select()
.eq('user_id', userId)
.order('created_at', ascending: false);
return data.map(NotificationItem.fromMap).toList();
},
fromMap: NotificationItem.fromMap,
channelName: 'notifications',
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
);
ref.onDispose(wrapper.dispose);
return wrapper.stream.map((result) => result.data);
}); });
final unreadNotificationsCountProvider = Provider<int>((ref) { final unreadNotificationsCountProvider = Provider<int>((ref) {
@@ -40,6 +60,79 @@ class NotificationsController {
final SupabaseClient _client; final SupabaseClient _client;
/// Internal helper that inserts notification rows and sends pushes if
/// [targetUserIds] is provided.
/// Internal helper that inserts notification rows and optionally sends
/// FCM pushes. Callers should use [createNotification] instead.
Future<void> _createAndPush(
List<Map<String, dynamic>> rows, {
List<String>? targetUserIds,
String? pushTitle,
String? pushBody,
Map<String, dynamic>? pushData,
}) async {
if (rows.isEmpty) return;
debugPrint(
'notifications_provider: inserting ${rows.length} rows; pushTitle=$pushTitle',
);
await _client.from('notifications').insert(rows);
// If target user IDs are provided, invoke client-side push
// flow by calling `sendPush`. We no longer rely on the DB trigger
// to call the edge function.
if (targetUserIds == null || targetUserIds.isEmpty) return;
debugPrint('notification rows inserted; invoking client push');
try {
await sendPush(
userIds: targetUserIds,
title: pushTitle ?? '',
body: pushBody ?? '',
data: pushData,
);
} catch (e) {
debugPrint('sendPush failed: $e');
}
}
/// Create a typed notification in the database. This method handles
/// inserting the row(s); a PostgreSQL trigger will forward the new row to
/// the `send_fcm` edge function, so clients do **not** directly invoke it
/// (avoids CORS/auth problems on web). The [pushTitle]/[pushBody] values
/// are still stored for the trigger payload.
Future<void> createNotification({
required List<String> userIds,
required String type,
required String actorId,
Map<String, dynamic>? fields,
String? pushTitle,
String? pushBody,
Map<String, dynamic>? pushData,
}) async {
debugPrint(
'createNotification called type=$type users=${userIds.length} pushTitle=$pushTitle pushBody=$pushBody',
);
if (userIds.isEmpty) return;
final rows = userIds.map((userId) {
return <String, dynamic>{
'user_id': userId,
'actor_id': actorId,
'type': type,
...?fields,
};
}).toList();
await _createAndPush(
rows,
targetUserIds: userIds,
pushTitle: pushTitle,
pushBody: pushBody,
pushData: pushData,
);
}
/// Convenience for mention-specific case; left for compatibility.
Future<void> createMentionNotifications({ Future<void> createMentionNotifications({
required List<String> userIds, required List<String> userIds,
required String actorId, required String actorId,
@@ -47,24 +140,22 @@ class NotificationsController {
String? ticketId, String? ticketId,
String? taskId, String? taskId,
}) async { }) async {
if (userIds.isEmpty) return; return createNotification(
if ((ticketId == null || ticketId.isEmpty) && userIds: userIds,
(taskId == null || taskId.isEmpty)) { type: 'mention',
return; actorId: actorId,
} fields: {
final rows = userIds
.map(
(userId) => {
'user_id': userId,
'actor_id': actorId,
'ticket_id': ticketId,
'task_id': taskId,
'message_id': messageId, 'message_id': messageId,
'type': 'mention', ...?(ticketId != null ? {'ticket_id': ticketId} : null),
...?(taskId != null ? {'task_id': taskId} : null),
}, },
) pushTitle: 'New mention',
.toList(); pushBody: 'You were mentioned in a message',
await _client.from('notifications').insert(rows); pushData: {
...?(ticketId != null ? {'ticket_id': ticketId} : null),
...?(taskId != null ? {'task_id': taskId} : null),
},
);
} }
Future<void> markRead(String id) async { Future<void> markRead(String id) async {
@@ -95,4 +186,88 @@ class NotificationsController {
.eq('user_id', userId) .eq('user_id', userId)
.filter('read_at', 'is', null); .filter('read_at', 'is', null);
} }
/// Store or update an FCM token for the current user.
Future<void> registerFcmToken(String token) async {
final userId = _client.auth.currentUser?.id;
if (userId == null) return;
final deviceId = await DeviceId.getId();
if (deviceId.isEmpty) {
debugPrint('registerFcmToken: device id missing; skipping');
return;
}
// upsert using a unique constraint on (user_id, device_id) so a single
// device keeps its token updated without overwriting other devices.
final payload = {
'user_id': userId,
'device_id': deviceId,
'token': token,
'created_at': DateTime.now().toUtc().toIso8601String(),
};
final res = await _client.from('fcm_tokens').upsert(payload);
final dyn = res as dynamic;
if (dyn.error != null) {
// duplicate key or RLS issue - just log it
debugPrint('registerFcmToken error: ${dyn.error?.message ?? dyn.error}');
} else {
debugPrint('registerFcmToken success for user=$userId token=$token');
}
}
/// Remove an FCM token (e.g. when the user logs out or uninstalls).
Future<void> unregisterFcmToken(String token) async {
final userId = _client.auth.currentUser?.id;
if (userId == null) return;
final deviceId = await DeviceId.getId();
// Prefer to delete by device_id to avoid removing other devices' tokens.
final res = await _client
.from('fcm_tokens')
.delete()
.eq('user_id', userId)
.eq('device_id', deviceId)
.or('token.eq.$token');
final uDyn = res as dynamic;
if (uDyn.error != null) {
debugPrint(
'unregisterFcmToken error: ${uDyn.error?.message ?? uDyn.error}',
);
}
}
/// Send a push message via the `send_fcm` edge function.
Future<void> sendPush({
List<String>? tokens,
List<String>? userIds,
required String title,
required String body,
Map<String, dynamic>? data,
}) async {
try {
if (tokens != null && tokens.isNotEmpty) {
debugPrint(
'invoking send_fcm with ${tokens.length} tokens, title="$title"',
);
} else if (userIds != null && userIds.isNotEmpty) {
debugPrint(
'invoking send_fcm with userIds=${userIds.length}, title="$title"',
);
} else {
debugPrint('sendPush called with neither tokens nor userIds');
return;
}
final bodyPayload = <String, dynamic>{
'tokens': tokens ?? [],
'user_ids': userIds ?? [],
'title': title,
'body': body,
'data': data ?? {},
};
final res = await _client.functions.invoke('send_fcm', body: bodyPayload);
debugPrint('send_fcm result: $res');
} catch (err) {
debugPrint('sendPush invocation error: $err');
}
}
} }
+301
View File
@@ -0,0 +1,301 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../models/pass_slip.dart';
import 'profile_provider.dart';
import 'supabase_provider.dart';
import 'stream_recovery.dart';
import 'realtime_controller.dart';
/// All visible pass slips (own for staff, all for admin/dispatcher).
final passSlipsProvider = StreamProvider<List<PassSlip>>((ref) {
final client = ref.watch(supabaseClientProvider);
final profileAsync = ref.watch(currentProfileProvider);
final profile = profileAsync.valueOrNull;
if (profile == null) return Stream.value(const <PassSlip>[]);
final isAdmin = profile.role == 'admin' || profile.role == 'dispatcher';
final hasFullAccess = isAdmin || profile.role == 'programmer';
final wrapper = StreamRecoveryWrapper<PassSlip>(
stream: hasFullAccess
? client
.from('pass_slips')
.stream(primaryKey: ['id'])
.order('requested_at', ascending: false)
: client
.from('pass_slips')
.stream(primaryKey: ['id'])
.eq('user_id', profile.id)
.order('requested_at', ascending: false),
onPollData: () async {
final query = client.from('pass_slips').select();
final data = hasFullAccess
? await query.order('requested_at', ascending: false)
: await query
.eq('user_id', profile.id)
.order('requested_at', ascending: false);
return data.map(PassSlip.fromMap).toList();
},
fromMap: PassSlip.fromMap,
channelName: 'pass_slips',
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
);
ref.onDispose(wrapper.dispose);
return wrapper.stream.map((result) => result.data);
});
/// Currently active pass slip for the logged-in user (approved, not completed).
final activePassSlipProvider = Provider<PassSlip?>((ref) {
final slips = ref.watch(passSlipsProvider).valueOrNull ?? [];
final userId = ref.watch(currentUserIdProvider);
if (userId == null) return null;
try {
return slips.firstWhere((s) => s.userId == userId && s.isActive);
} catch (_) {
return null;
}
});
/// Active pass slips for all users (for dashboard IT Staff Pulse).
final activePassSlipsProvider = Provider<List<PassSlip>>((ref) {
final slips = ref.watch(passSlipsProvider).valueOrNull ?? [];
return slips.where((s) => s.isActive).toList();
});
final passSlipControllerProvider = Provider<PassSlipController>((ref) {
final client = ref.watch(supabaseClientProvider);
return PassSlipController(client);
});
class PassSlipController {
PassSlipController(this._client);
final SupabaseClient _client;
Future<void> requestSlip({
required String dutyScheduleId,
required String reason,
}) async {
final userId = _client.auth.currentUser?.id;
if (userId == null) throw Exception('Not authenticated');
final payload = {
'user_id': userId,
'duty_schedule_id': dutyScheduleId,
'reason': reason,
'status': 'pending',
'requested_at': DateTime.now().toUtc().toIso8601String(),
};
final insertedRaw = await _client
.from('pass_slips')
.insert(payload)
.select()
.maybeSingle();
final Map<String, dynamic>? inserted = insertedRaw is Map<String, dynamic>
? insertedRaw
: null;
// Notify admins for approval
try {
final adminIds = await _fetchRoleUserIds(
roles: const ['admin'],
excludeUserId: userId,
);
if (adminIds.isEmpty) return;
// Resolve actor display name for nice push text
String actorName = 'Someone';
try {
final p = await _client
.from('profiles')
.select('full_name,display_name,name')
.eq('id', userId)
.maybeSingle();
if (p != null) {
if (p['full_name'] != null) {
actorName = p['full_name'].toString();
} else if (p['display_name'] != null) {
actorName = p['display_name'].toString();
} else if (p['name'] != null) {
actorName = p['name'].toString();
}
}
} catch (_) {}
final slipId = (inserted ?? <String, dynamic>{})['id']?.toString() ?? '';
final title = 'Pass Slip Filed for Approval';
final body = '$actorName filed a pass slip that requires approval.';
final notificationId = (inserted ?? <String, dynamic>{})['id']
?.toString();
final dataPayload = <String, dynamic>{
'type': 'pass_slip_filed',
'pass_slip_id': slipId,
...?(notificationId != null
? {'notification_id': notificationId}
: null),
};
await _client
.from('notifications')
.insert(
adminIds
.map(
(adminId) => {
'user_id': adminId,
'actor_id': userId,
'type': 'pass_slip_filed',
'pass_slip_id': slipId,
},
)
.toList(),
);
final res = await _client.functions.invoke(
'send_fcm',
body: {
'user_ids': adminIds,
'title': title,
'body': body,
'data': dataPayload,
},
);
debugPrint('pass slip send_fcm result: $res');
} catch (e) {
debugPrint('pass slip send_fcm error: $e');
// Non-fatal: keep slip request working even if send_fcm fails
}
}
Future<void> approveSlip(String slipId) async {
final userId = _client.auth.currentUser?.id;
if (userId == null) throw Exception('Not authenticated');
await _client
.from('pass_slips')
.update({
'status': 'approved',
'approved_by': userId,
'approved_at': DateTime.now().toUtc().toIso8601String(),
'slip_start': DateTime.now().toUtc().toIso8601String(),
})
.eq('id', slipId);
await _notifyRequester(slipId: slipId, actorId: userId, approved: true);
}
Future<void> rejectSlip(String slipId) async {
final userId = _client.auth.currentUser?.id;
if (userId == null) throw Exception('Not authenticated');
await _client
.from('pass_slips')
.update({
'status': 'rejected',
'approved_by': userId,
'approved_at': DateTime.now().toUtc().toIso8601String(),
})
.eq('id', slipId);
await _notifyRequester(slipId: slipId, actorId: userId, approved: false);
}
Future<void> _notifyRequester({
required String slipId,
required String actorId,
required bool approved,
}) async {
try {
final row = await _client
.from('pass_slips')
.select('user_id')
.eq('id', slipId)
.maybeSingle();
// ignore: unnecessary_cast
final rowMap = row as Map<String, dynamic>?;
final userId = rowMap?['user_id']?.toString();
if (userId == null || userId.isEmpty) return;
String actorName = 'Someone';
try {
final p = await _client
.from('profiles')
.select('full_name,display_name,name')
.eq('id', actorId)
.maybeSingle();
if (p != null) {
if (p['full_name'] != null) {
actorName = p['full_name'].toString();
} else if (p['display_name'] != null) {
actorName = p['display_name'].toString();
} else if (p['name'] != null) {
actorName = p['name'].toString();
}
}
} catch (_) {}
final title = approved ? 'Pass Slip Approved' : 'Pass Slip Rejected';
final body = approved
? '$actorName approved your pass slip.'
: '$actorName rejected your pass slip.';
final dataPayload = <String, dynamic>{
'type': approved ? 'pass_slip_approved' : 'pass_slip_rejected',
'pass_slip_id': slipId,
};
await _client.from('notifications').insert({
'user_id': userId,
'actor_id': actorId,
'type': approved ? 'pass_slip_approved' : 'pass_slip_rejected',
'pass_slip_id': slipId,
});
await _client.functions.invoke(
'send_fcm',
body: {
'user_ids': [userId],
'title': title,
'body': body,
'data': dataPayload,
},
);
} catch (_) {
// non-fatal
}
}
Future<void> completeSlip(String slipId) async {
await _client
.from('pass_slips')
.update({
'status': 'completed',
'slip_end': DateTime.now().toUtc().toIso8601String(),
})
.eq('id', slipId);
}
Future<List<String>> _fetchRoleUserIds({
required List<String> roles,
required String? excludeUserId,
}) async {
try {
final data = await _client
.from('profiles')
.select('id, role')
.inFilter('role', roles);
final rows = data as List<dynamic>;
final ids = rows
.map((row) => row['id'] as String?)
.whereType<String>()
.where((id) => id.isNotEmpty && id != excludeUserId)
.toList();
return ids;
} catch (_) {
return [];
}
}
}
+91 -7
View File
@@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:typed_data';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:supabase_flutter/supabase_flutter.dart';
@@ -6,6 +7,7 @@ import 'package:supabase_flutter/supabase_flutter.dart';
import '../models/profile.dart'; import '../models/profile.dart';
import 'auth_provider.dart'; import 'auth_provider.dart';
import 'supabase_provider.dart'; import 'supabase_provider.dart';
import 'stream_recovery.dart';
final currentUserIdProvider = Provider<String?>((ref) { final currentUserIdProvider = Provider<String?>((ref) {
final authState = ref.watch(authStateChangesProvider); final authState = ref.watch(authStateChangesProvider);
@@ -23,20 +25,43 @@ final currentProfileProvider = StreamProvider<Profile?>((ref) {
return const Stream.empty(); return const Stream.empty();
} }
final client = ref.watch(supabaseClientProvider); final client = ref.watch(supabaseClientProvider);
return client
final wrapper = StreamRecoveryWrapper<Profile?>(
stream: client.from('profiles').stream(primaryKey: ['id']).eq('id', userId),
onPollData: () async {
final data = await client
.from('profiles') .from('profiles')
.stream(primaryKey: ['id']) .select()
.eq('id', userId) .eq('id', userId)
.map((rows) => rows.isEmpty ? null : Profile.fromMap(rows.first)); .maybeSingle();
return data == null ? [] : [Profile.fromMap(data)];
},
fromMap: Profile.fromMap,
);
ref.onDispose(wrapper.dispose);
return wrapper.stream.map((result) {
return result.data.isEmpty ? null : result.data.first;
});
}); });
final profilesProvider = StreamProvider<List<Profile>>((ref) { final profilesProvider = StreamProvider<List<Profile>>((ref) {
final client = ref.watch(supabaseClientProvider); final client = ref.watch(supabaseClientProvider);
return client
final wrapper = StreamRecoveryWrapper<Profile>(
stream: client
.from('profiles') .from('profiles')
.stream(primaryKey: ['id']) .stream(primaryKey: ['id'])
.order('full_name') .order('full_name'),
.map((rows) => rows.map(Profile.fromMap).toList()); onPollData: () async {
final data = await client.from('profiles').select().order('full_name');
return data.map(Profile.fromMap).toList();
},
fromMap: Profile.fromMap,
);
ref.onDispose(wrapper.dispose);
return wrapper.stream.map((result) => result.data);
}); });
/// Controller for the current user's profile (update full name / password). /// Controller for the current user's profile (update full name / password).
@@ -68,12 +93,71 @@ class ProfileController {
} }
await _client.auth.updateUser(UserAttributes(password: password)); await _client.auth.updateUser(UserAttributes(password: password));
} }
/// Upload a profile avatar image and update the profile record.
Future<String> uploadAvatar({
required String userId,
required Uint8List bytes,
required String fileName,
}) async {
final ext = fileName.split('.').last.toLowerCase();
final path = '$userId/avatar.$ext';
await _client.storage
.from('avatars')
.uploadBinary(
path,
bytes,
fileOptions: const FileOptions(upsert: true),
);
final url = _client.storage.from('avatars').getPublicUrl(path);
await _client.from('profiles').update({'avatar_url': url}).eq('id', userId);
return url;
}
/// Upload a face enrollment photo and update the profile record.
Future<String> uploadFacePhoto({
required String userId,
required Uint8List bytes,
required String fileName,
}) async {
final ext = fileName.split('.').last.toLowerCase();
final path = '$userId/face.$ext';
await _client.storage
.from('face-enrollment')
.uploadBinary(
path,
bytes,
fileOptions: const FileOptions(upsert: true),
);
final url = _client.storage.from('face-enrollment').getPublicUrl(path);
await _client
.from('profiles')
.update({
'face_photo_url': url,
'face_enrolled_at': DateTime.now().toUtc().toIso8601String(),
})
.eq('id', userId);
return url;
}
/// Download the face enrollment photo bytes for the given user.
/// Uses Supabase authenticated storage API (works with private buckets).
Future<Uint8List?> downloadFacePhoto(String userId) async {
try {
return await _client.storage
.from('face-enrollment')
.download('$userId/face.jpg');
} catch (_) {
return null;
}
}
} }
final isAdminProvider = Provider<bool>((ref) { final isAdminProvider = Provider<bool>((ref) {
final profileAsync = ref.watch(currentProfileProvider); final profileAsync = ref.watch(currentProfileProvider);
return profileAsync.maybeWhen( return profileAsync.maybeWhen(
data: (profile) => profile?.role == 'admin', data: (profile) =>
profile?.role == 'admin' || profile?.role == 'programmer',
orElse: () => false, orElse: () => false,
); );
}); });
+106
View File
@@ -0,0 +1,106 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../models/app_settings.dart';
import 'supabase_provider.dart';
/// Fetches the Ramadan configuration from app_settings.
final ramadanConfigProvider = FutureProvider<RamadanConfig>((ref) async {
final client = ref.watch(supabaseClientProvider);
final data = await client
.from('app_settings')
.select()
.eq('key', 'ramadan_mode')
.maybeSingle();
if (data == null) return RamadanConfig(enabled: false, autoDetect: true);
final setting = AppSetting.fromMap(data);
return RamadanConfig.fromJson(setting.value);
});
/// Whether Ramadan mode is currently active.
/// Combines manual toggle with auto-detection via Hijri calendar approximation.
final isRamadanActiveProvider = Provider<bool>((ref) {
final config = ref.watch(ramadanConfigProvider).valueOrNull;
if (config == null) return false;
// Manual override takes priority
if (config.enabled) return true;
// Auto-detect based on approximate Hijri calendar
if (config.autoDetect) {
return isApproximateRamadan(DateTime.now());
}
return false;
});
/// Approximate Ramadan detection using a simplified Hijri calendar calculation.
/// Ramadan moves ~11 days earlier each Gregorian year.
/// This provides a reasonable approximation; for exact dates, a Hijri calendar
/// package could be used.
bool isApproximateRamadan(DateTime now) {
// Known Ramadan start dates (approximate):
// 2025: Feb 28 - Mar 30
// 2026: Feb 17 - Mar 19
// 2027: Feb 7 - Mar 8
// 2028: Jan 27 - Feb 25
final ramadanWindows = {
2025: (start: DateTime(2025, 2, 28), end: DateTime(2025, 3, 30)),
2026: (start: DateTime(2026, 2, 17), end: DateTime(2026, 3, 19)),
2027: (start: DateTime(2027, 2, 7), end: DateTime(2027, 3, 8)),
2028: (start: DateTime(2028, 1, 27), end: DateTime(2028, 2, 25)),
};
final window = ramadanWindows[now.year];
if (window != null) {
return !now.isBefore(window.start) && !now.isAfter(window.end);
}
return false;
}
final ramadanControllerProvider = Provider<RamadanController>((ref) {
final client = ref.watch(supabaseClientProvider);
return RamadanController(client);
});
class RamadanController {
RamadanController(this._client);
final SupabaseClient _client;
Future<void> setEnabled(bool enabled) async {
final current = await _client
.from('app_settings')
.select()
.eq('key', 'ramadan_mode')
.maybeSingle();
final value = current != null
? Map<String, dynamic>.from(current['value'] as Map)
: <String, dynamic>{'auto_detect': true};
value['enabled'] = enabled;
await _client.from('app_settings').upsert({
'key': 'ramadan_mode',
'value': value,
});
}
Future<void> setAutoDetect(bool autoDetect) async {
final current = await _client
.from('app_settings')
.select()
.eq('key', 'ramadan_mode')
.maybeSingle();
final value = current != null
? Map<String, dynamic>.from(current['value'] as Map)
: <String, dynamic>{'enabled': false};
value['auto_detect'] = autoDetect;
await _client.from('app_settings').upsert({
'key': 'ramadan_mode',
'value': value,
});
}
}
+127
View File
@@ -0,0 +1,127 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'stream_recovery.dart';
import 'supabase_provider.dart';
final realtimeControllerProvider = ChangeNotifierProvider<RealtimeController>((
ref,
) {
final client = ref.watch(supabaseClientProvider);
final controller = RealtimeController(client);
ref.onDispose(controller.dispose);
return controller;
});
/// Per-channel realtime controller for UI skeleton indicators.
///
/// Individual streams handle their own recovery via [StreamRecoveryWrapper].
/// This controller aggregates channel-level status so the UI can show
/// per-channel skeleton shimmers (e.g. only the tasks list shimmers when
/// the `tasks` channel is recovering, not the whole app).
///
/// Coordinates:
/// - Per-channel recovering state for pinpoint skeleton indicators
/// - Auth token refreshes for realtime connections
class RealtimeController extends ChangeNotifier {
final SupabaseClient _client;
bool _disposed = false;
/// Channels currently in a recovering/polling/stale state.
final Set<String> _recoveringChannels = {};
RealtimeController(this._client) {
_init();
}
void _init() {
try {
_client.auth.onAuthStateChange.listen((data) {
final event = data.event;
if (event == AuthChangeEvent.tokenRefreshed) {
_ensureTokenFresh();
}
});
} catch (e) {
debugPrint('RealtimeController._init error: $e');
}
}
Future<void> _ensureTokenFresh() async {
if (_disposed) return;
try {
final authDynamic = _client.auth as dynamic;
if (authDynamic.refreshSession != null) {
await authDynamic.refreshSession?.call();
}
} catch (e) {
debugPrint('RealtimeController: token refresh failed: $e');
}
}
// Per-channel status
/// Whether a specific channel is currently recovering.
bool isChannelRecovering(String channel) =>
_recoveringChannels.contains(channel);
/// Global flag: true if **any** channel is recovering. Useful for global
/// indicators (e.g. dashboard) where per-channel granularity isn't needed.
bool get isAnyStreamRecovering => _recoveringChannels.isNotEmpty;
/// The set of channels currently recovering, for UI display.
Set<String> get recoveringChannels => Set.unmodifiable(_recoveringChannels);
/// Mark a channel as recovering. Called by [StreamRecoveryWrapper] via its
/// [ChannelStatusCallback].
void markChannelRecovering(String channel) {
if (_recoveringChannels.add(channel)) {
notifyListeners();
}
}
/// Mark a channel as recovered. Called when realtime reconnects
/// successfully.
void markChannelRecovered(String channel) {
if (_recoveringChannels.remove(channel)) {
notifyListeners();
}
}
/// Convenience callback suitable for [StreamRecoveryWrapper.onStatusChanged].
///
/// Routes [StreamConnectionStatus] to the appropriate mark method.
/// Both `connected` and `polling` are treated as "recovered" because
/// polling is a functional fallback that still delivers data the user
/// doesn't need to see a reconnection indicator while data flows via REST.
void handleChannelStatus(String channel, StreamConnectionStatus status) {
if (status == StreamConnectionStatus.connected ||
status == StreamConnectionStatus.polling) {
markChannelRecovered(channel);
} else {
markChannelRecovering(channel);
}
}
// Legacy compat
/// @deprecated Use [markChannelRecovering] instead.
void markStreamRecovering() {
// Kept for backward compatibility; maps to a synthetic channel.
markChannelRecovering('_global');
}
/// @deprecated Use [markChannelRecovered] instead.
void markStreamRecovered() {
markChannelRecovered('_global');
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
}
+412
View File
@@ -0,0 +1,412 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../utils/app_time.dart';
import 'supabase_provider.dart';
//
// Report date-range model
//
/// Describes the selected date range along with a human-readable label
/// (e.g. "Last 30 Days", "This Month").
@immutable
class ReportDateRange {
const ReportDateRange({
required this.start,
required this.end,
required this.label,
});
final DateTime start;
final DateTime end;
final String label;
DateTimeRange get dateTimeRange => DateTimeRange(start: start, end: end);
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ReportDateRange &&
runtimeType == other.runtimeType &&
start == other.start &&
end == other.end &&
label == other.label;
@override
int get hashCode => Object.hash(start, end, label);
}
/// Builds a default date range of "Last 30 Days" in the Asia/Manila timezone.
ReportDateRange _defaultDateRange() {
final now = AppTime.now();
final today = DateTime(now.year, now.month, now.day);
final start = today.subtract(const Duration(days: 30));
final end = today.add(const Duration(days: 1));
return ReportDateRange(start: start, end: end, label: 'Last 30 Days');
}
final reportDateRangeProvider = StateProvider<ReportDateRange>((ref) {
return _defaultDateRange();
});
//
// Report widget toggle (which charts to show)
//
/// Each report widget that can be toggled on/off.
enum ReportWidgetType {
ticketsByStatus('Tickets by Status', ReportSection.counts),
tasksByStatus('Tasks by Status', ReportSection.counts),
conversionRate('Ticket-to-Task Conversion', ReportSection.counts),
requestType('Request Type Distribution', ReportSection.counts),
requestCategory('Request Category Distribution', ReportSection.counts),
tasksByHour('Tasks Created by Hour', ReportSection.trends),
ticketsByHour('Tickets Created by Hour', ReportSection.trends),
monthlyOverview('Monthly Overview', ReportSection.trends),
topOfficesTickets('Top Offices by Tickets', ReportSection.rankings),
topOfficesTasks('Top Offices by Tasks', ReportSection.rankings),
topTicketSubjects('Top Ticket Subjects', ReportSection.rankings),
topTaskSubjects('Top Task Subjects', ReportSection.rankings),
avgResolution('Avg Resolution Time by Office', ReportSection.performance),
staffWorkload('IT Staff Workload', ReportSection.performance);
const ReportWidgetType(this.label, this.section);
final String label;
final ReportSection section;
}
enum ReportSection {
counts('Counts'),
trends('Trends'),
rankings('Rankings'),
performance('Performance');
const ReportSection(this.label);
final String label;
}
/// Which widgets to include on the reports screen (and in PDF export).
/// Defaults to all enabled.
final reportWidgetToggleProvider = StateProvider<Set<ReportWidgetType>>(
(ref) => ReportWidgetType.values.toSet(),
);
//
// Helper: build RPC params from the current date range
//
Map<String, dynamic> _rangeParams(ReportDateRange range) => {
'p_start': range.start.toUtc().toIso8601String(),
'p_end': range.end.toUtc().toIso8601String(),
};
//
// FutureProviders one per report RPC
//
/// Generic helper to call an RPC and parse each row with [mapper].
Future<List<T>> _callRpc<T>(
Ref ref,
String rpcName,
Map<String, dynamic> params,
T Function(Map<String, dynamic>) mapper,
) async {
final client = ref.read(supabaseClientProvider);
final response = await client.rpc(rpcName, params: params);
final list = response as List<dynamic>;
return list.map((e) => mapper(Map<String, dynamic>.from(e as Map))).toList();
}
// --- 1. Tickets by status ---
final ticketsByStatusReportProvider =
FutureProvider.autoDispose<List<StatusCount>>((ref) {
final range = ref.watch(reportDateRangeProvider);
return _callRpc(
ref,
'report_tickets_by_status',
_rangeParams(range),
(m) => StatusCount(
status: m['status'] as String? ?? 'unknown',
count: (m['count'] as num?)?.toInt() ?? 0,
),
);
});
// --- 2. Tasks by status ---
final tasksByStatusReportProvider =
FutureProvider.autoDispose<List<StatusCount>>((ref) {
final range = ref.watch(reportDateRangeProvider);
return _callRpc(
ref,
'report_tasks_by_status',
_rangeParams(range),
(m) => StatusCount(
status: m['status'] as String? ?? 'unknown',
count: (m['count'] as num?)?.toInt() ?? 0,
),
);
});
// --- 3. Tasks by hour ---
final tasksByHourReportProvider = FutureProvider.autoDispose<List<HourCount>>((
ref,
) {
final range = ref.watch(reportDateRangeProvider);
return _callRpc(
ref,
'report_tasks_by_hour',
_rangeParams(range),
(m) => HourCount(
hour: (m['hour'] as num?)?.toInt() ?? 0,
count: (m['count'] as num?)?.toInt() ?? 0,
),
);
});
// --- 4. Tickets by hour ---
final ticketsByHourReportProvider = FutureProvider.autoDispose<List<HourCount>>(
(ref) {
final range = ref.watch(reportDateRangeProvider);
return _callRpc(
ref,
'report_tickets_by_hour',
_rangeParams(range),
(m) => HourCount(
hour: (m['hour'] as num?)?.toInt() ?? 0,
count: (m['count'] as num?)?.toInt() ?? 0,
),
);
},
);
// --- 5. Top offices by tickets ---
final topOfficesTicketsReportProvider =
FutureProvider.autoDispose<List<NamedCount>>((ref) {
final range = ref.watch(reportDateRangeProvider);
return _callRpc(
ref,
'report_top_offices_tickets',
{..._rangeParams(range), 'p_limit': 10},
(m) => NamedCount(
name: m['office_name'] as String? ?? '',
count: (m['count'] as num?)?.toInt() ?? 0,
),
);
});
// --- 6. Top offices by tasks ---
final topOfficesTasksReportProvider =
FutureProvider.autoDispose<List<NamedCount>>((ref) {
final range = ref.watch(reportDateRangeProvider);
return _callRpc(
ref,
'report_top_offices_tasks',
{..._rangeParams(range), 'p_limit': 10},
(m) => NamedCount(
name: m['office_name'] as String? ?? '',
count: (m['count'] as num?)?.toInt() ?? 0,
),
);
});
// --- 7. Top ticket subjects ---
final topTicketSubjectsReportProvider =
FutureProvider.autoDispose<List<NamedCount>>((ref) {
final range = ref.watch(reportDateRangeProvider);
return _callRpc(
ref,
'report_top_ticket_subjects',
{..._rangeParams(range), 'p_limit': 10, 'p_threshold': 0.4},
(m) => NamedCount(
name: m['subject_group'] as String? ?? '',
count: (m['ticket_count'] as num?)?.toInt() ?? 0,
),
);
});
// --- 8. Top task subjects ---
final topTaskSubjectsReportProvider =
FutureProvider.autoDispose<List<NamedCount>>((ref) {
final range = ref.watch(reportDateRangeProvider);
return _callRpc(
ref,
'report_top_task_subjects',
{..._rangeParams(range), 'p_limit': 10, 'p_threshold': 0.4},
(m) => NamedCount(
name: m['subject_group'] as String? ?? '',
count: (m['task_count'] as num?)?.toInt() ?? 0,
),
);
});
// --- 9. Monthly overview ---
final monthlyOverviewReportProvider =
FutureProvider.autoDispose<List<MonthlyOverview>>((ref) {
final range = ref.watch(reportDateRangeProvider);
return _callRpc(
ref,
'report_monthly_overview',
_rangeParams(range),
(m) => MonthlyOverview(
month: m['month'] as String? ?? '',
ticketCount: (m['ticket_count'] as num?)?.toInt() ?? 0,
taskCount: (m['task_count'] as num?)?.toInt() ?? 0,
),
);
});
// --- 10. Request type distribution ---
final requestTypeReportProvider = FutureProvider.autoDispose<List<NamedCount>>((
ref,
) {
final range = ref.watch(reportDateRangeProvider);
return _callRpc(
ref,
'report_request_type_distribution',
_rangeParams(range),
(m) => NamedCount(
name: m['request_type'] as String? ?? 'Unspecified',
count: (m['count'] as num?)?.toInt() ?? 0,
),
);
});
// --- 11. Request category distribution ---
final requestCategoryReportProvider =
FutureProvider.autoDispose<List<NamedCount>>((ref) {
final range = ref.watch(reportDateRangeProvider);
return _callRpc(
ref,
'report_request_category_distribution',
_rangeParams(range),
(m) => NamedCount(
name: m['request_category'] as String? ?? 'Unspecified',
count: (m['count'] as num?)?.toInt() ?? 0,
),
);
});
// --- 12. Avg resolution time by office ---
final avgResolutionReportProvider =
FutureProvider.autoDispose<List<OfficeResolution>>((ref) {
final range = ref.watch(reportDateRangeProvider);
return _callRpc(
ref,
'report_avg_resolution_by_office',
_rangeParams(range),
(m) => OfficeResolution(
officeName: m['office_name'] as String? ?? '',
avgHours: (m['avg_hours'] as num?)?.toDouble() ?? 0,
),
);
});
// --- 13. Staff workload ---
final staffWorkloadReportProvider =
FutureProvider.autoDispose<List<StaffWorkload>>((ref) {
final range = ref.watch(reportDateRangeProvider);
return _callRpc(
ref,
'report_staff_workload',
_rangeParams(range),
(m) => StaffWorkload(
staffName: m['staff_name'] as String? ?? '',
assignedCount: (m['assigned_count'] as num?)?.toInt() ?? 0,
completedCount: (m['completed_count'] as num?)?.toInt() ?? 0,
),
);
});
// --- 14. Ticket-to-task conversion rate ---
final ticketToTaskRateReportProvider =
FutureProvider.autoDispose<ConversionRate>((ref) async {
final range = ref.watch(reportDateRangeProvider);
final client = ref.read(supabaseClientProvider);
final response = await client.rpc(
'report_ticket_to_task_rate',
params: _rangeParams(range),
);
final list = response as List<dynamic>;
if (list.isEmpty) {
return const ConversionRate(
totalTickets: 0,
promotedTickets: 0,
conversionRate: 0,
);
}
final m = Map<String, dynamic>.from(list.first as Map);
return ConversionRate(
totalTickets: (m['total_tickets'] as num?)?.toInt() ?? 0,
promotedTickets: (m['promoted_tickets'] as num?)?.toInt() ?? 0,
conversionRate: (m['conversion_rate'] as num?)?.toDouble() ?? 0,
);
});
//
// Data models for report results
//
@immutable
class StatusCount {
const StatusCount({required this.status, required this.count});
final String status;
final int count;
}
@immutable
class HourCount {
const HourCount({required this.hour, required this.count});
final int hour;
final int count;
}
@immutable
class NamedCount {
const NamedCount({required this.name, required this.count});
final String name;
final int count;
}
@immutable
class MonthlyOverview {
const MonthlyOverview({
required this.month,
required this.ticketCount,
required this.taskCount,
});
final String month;
final int ticketCount;
final int taskCount;
}
@immutable
class OfficeResolution {
const OfficeResolution({required this.officeName, required this.avgHours});
final String officeName;
final double avgHours;
}
@immutable
class StaffWorkload {
const StaffWorkload({
required this.staffName,
required this.assignedCount,
required this.completedCount,
});
final String staffName;
final int assignedCount;
final int completedCount;
}
@immutable
class ConversionRate {
const ConversionRate({
required this.totalTickets,
required this.promotedTickets,
required this.conversionRate,
});
final int totalTickets;
final int promotedTickets;
final double conversionRate;
}
@@ -0,0 +1,81 @@
import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/rotation_config.dart';
import 'supabase_provider.dart';
/// Key used to store the rotation configuration in `app_settings`.
const _settingsKey = 'rotation_config';
/// Provides the current [RotationConfig] from `app_settings`.
final rotationConfigProvider = FutureProvider<RotationConfig>((ref) async {
final client = ref.watch(supabaseClientProvider);
final row = await client
.from('app_settings')
.select()
.eq('key', _settingsKey)
.maybeSingle();
if (row == null) return RotationConfig();
final value = row['value'];
if (value is Map<String, dynamic>) {
return RotationConfig.fromJson(value);
}
if (value is String) {
return RotationConfig.fromJson(jsonDecode(value) as Map<String, dynamic>);
}
return RotationConfig();
});
/// Controller for persisting [RotationConfig] changes.
final rotationConfigControllerProvider = Provider<RotationConfigController>((
ref,
) {
final client = ref.watch(supabaseClientProvider);
return RotationConfigController(client, ref);
});
class RotationConfigController {
RotationConfigController(this._client, this._ref);
final dynamic _client;
final Ref _ref;
Future<void> save(RotationConfig config) async {
await _client.from('app_settings').upsert({
'key': _settingsKey,
'value': config.toJson(),
});
_ref.invalidate(rotationConfigProvider);
}
Future<void> updateShiftTypes(List<ShiftTypeConfig> shiftTypes) async {
final config = await _ref.read(rotationConfigProvider.future);
await save(config.copyWith(shiftTypes: shiftTypes));
}
Future<void> updateRoleWeeklyHours(Map<String, int> roleWeeklyHours) async {
final config = await _ref.read(rotationConfigProvider.future);
await save(config.copyWith(roleWeeklyHours: roleWeeklyHours));
}
Future<void> updateHolidays(List<Holiday> holidays) async {
final config = await _ref.read(rotationConfigProvider.future);
await save(config.copyWith(holidays: holidays));
}
Future<void> setHolidaySync({
required bool enabled,
required int year,
required List<Holiday> holidays,
}) async {
final config = await _ref.read(rotationConfigProvider.future);
await save(
config.copyWith(
syncPhilippinesHolidays: enabled,
holidaysYear: year,
holidays: holidays,
),
);
}
}
+16 -5
View File
@@ -2,14 +2,25 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/service.dart'; import '../models/service.dart';
import 'supabase_provider.dart'; import 'supabase_provider.dart';
import 'stream_recovery.dart';
import 'realtime_controller.dart';
final servicesProvider = StreamProvider<List<Service>>((ref) { final servicesProvider = StreamProvider<List<Service>>((ref) {
final client = ref.watch(supabaseClientProvider); final client = ref.watch(supabaseClientProvider);
return client
.from('services') final wrapper = StreamRecoveryWrapper<Service>(
.stream(primaryKey: ['id']) stream: client.from('services').stream(primaryKey: ['id']).order('name'),
.order('name') onPollData: () async {
.map((rows) => rows.map((r) => Service.fromMap(r)).toList()); final data = await client.from('services').select().order('name');
return data.map(Service.fromMap).toList();
},
fromMap: Service.fromMap,
channelName: 'services',
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
);
ref.onDispose(wrapper.dispose);
return wrapper.stream.map((result) => result.data);
}); });
final servicesOnceProvider = FutureProvider<List<Service>>((ref) async { final servicesOnceProvider = FutureProvider<List<Service>>((ref) async {
+404
View File
@@ -0,0 +1,404 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
/// Connection status for a single stream subscription.
enum StreamConnectionStatus {
/// Connected and receiving live updates.
connected,
/// Attempting to recover the connection; data may be stale.
recovering,
/// Connection failed; attempting to fallback to polling.
polling,
/// Connection and polling both failed; data is stale.
stale,
/// Fatal error; stream will not recover without manual intervention.
failed,
}
/// Represents the result of a polling attempt.
class PollResult<T> {
final List<T> data;
final bool success;
final String? error;
PollResult({required this.data, required this.success, this.error});
}
/// Configuration for stream recovery behavior.
class StreamRecoveryConfig {
/// Maximum number of automatic recovery attempts before giving up.
final int maxRecoveryAttempts;
/// Initial delay (in milliseconds) before first recovery attempt.
final int initialDelayMs;
/// Maximum delay (in milliseconds) for exponential backoff.
final int maxDelayMs;
/// Multiplier for exponential backoff (e.g., 2.0 = double each attempt).
final double backoffMultiplier;
/// Enable polling fallback when realtime fails.
final bool enablePollingFallback;
/// Polling interval (in milliseconds) when realtime is unavailable.
final int pollingIntervalMs;
const StreamRecoveryConfig({
this.maxRecoveryAttempts = 4,
this.initialDelayMs = 1000,
this.maxDelayMs = 32000, // 32 seconds max
this.backoffMultiplier = 2.0,
this.enablePollingFallback = true,
this.pollingIntervalMs = 5000, // Poll every 5 seconds
});
}
/// Callback type for per-channel status change notifications.
///
/// Used by [StreamRecoveryWrapper] to notify [RealtimeController] about
/// individual channel recovery state so the UI can show per-channel skeletons.
typedef ChannelStatusCallback =
void Function(String channel, StreamConnectionStatus status);
/// Wraps a Supabase realtime stream with automatic recovery, polling fallback,
/// and connection status tracking. Provides graceful degradation when the
/// realtime connection fails.
///
/// Error handling:
/// - **Timeout**: detected and handled internally with exponential backoff.
/// - **ChannelRateLimitReached**: detected and handled with a longer minimum
/// delay (5 s) before retrying. During recovery, a REST poll keeps data
/// fresh so the UI shows shimmer/skeleton instead of an error.
/// - **Generic errors**: same recovery flow with standard backoff.
///
/// Errors are **never** forwarded to consumers; instead the wrapper emits
/// [StreamRecoveryResult] events with appropriate [connectionStatus] so the
/// UI can react per-channel (e.g. show skeleton shimmer).
///
/// Usage:
/// ```dart
/// final wrapper = StreamRecoveryWrapper<Task>(
/// stream: client.from('tasks').stream(primaryKey: ['id']),
/// onPollData: () => fetchTasksViaRest(),
/// fromMap: Task.fromMap,
/// channelName: 'tasks',
/// onStatusChanged: (channel, status) { ... },
/// );
/// ```
class StreamRecoveryWrapper<T> {
final Stream<List<Map<String, dynamic>>> _realtimeStream;
final Future<List<T>> Function() _onPollData;
final T Function(Map<String, dynamic>) _fromMap;
final StreamRecoveryConfig _config;
/// Human-readable channel name for logging and per-channel status tracking.
final String channelName;
/// Optional callback invoked whenever this channel's connection status
/// changes. Used to integrate with [RealtimeController] for per-channel
/// skeleton indicators in the UI.
final ChannelStatusCallback? _onStatusChanged;
StreamConnectionStatus _connectionStatus = StreamConnectionStatus.connected;
int _recoveryAttempts = 0;
Timer? _pollingTimer;
Timer? _recoveryTimer;
Timer? _stabilityTimer;
StreamSubscription<List<Map<String, dynamic>>>? _realtimeSub;
StreamController<StreamRecoveryResult<T>>? _controller;
bool _disposed = false;
bool _listening = false;
StreamRecoveryWrapper({
required Stream<List<Map<String, dynamic>>> stream,
required Future<List<T>> Function() onPollData,
required T Function(Map<String, dynamic>) fromMap,
StreamRecoveryConfig config = const StreamRecoveryConfig(),
this.channelName = 'unknown',
ChannelStatusCallback? onStatusChanged,
}) : _realtimeStream = stream,
_onPollData = onPollData,
_fromMap = fromMap,
_config = config,
_onStatusChanged = onStatusChanged;
/// The wrapped stream that emits recovery results with metadata.
///
/// Lazily initializes the internal controller and starts listening to the
/// realtime stream on first access. Errors from the realtime channel are
/// handled internally consumers only see [StreamRecoveryResult] events.
Stream<StreamRecoveryResult<T>> get stream {
if (_controller == null) {
_controller = StreamController<StreamRecoveryResult<T>>.broadcast();
_startRealtimeSubscription();
}
return _controller!.stream;
}
/// Current connection status of this stream.
StreamConnectionStatus get connectionStatus => _connectionStatus;
// Realtime subscription
void _startRealtimeSubscription() {
if (_disposed || _listening) return;
_listening = true;
_realtimeSub?.cancel();
_realtimeSub = _realtimeStream.listen(
_onRealtimeData,
onError: _onRealtimeError,
onDone: _onRealtimeDone,
cancelOnError: false, // keep listening even after transient errors
);
}
void _onRealtimeData(List<Map<String, dynamic>> rows) {
if (_disposed) return;
// When recovering, don't reset _recoveryAttempts immediately.
// Supabase streams emit an initial REST fetch before the realtime
// channel is established. If the channel keeps failing, resetting
// on that REST data creates an infinite loop (data reset subscribe
// fail data reset ). Instead, start a stability timer only
// reset after staying connected without errors for 30 seconds.
if (_recoveryAttempts > 0) {
_stabilityTimer?.cancel();
_stabilityTimer = Timer(const Duration(seconds: 30), () {
if (!_disposed) {
_recoveryAttempts = 0;
debugPrint(
'StreamRecoveryWrapper[$channelName]: '
'connection stable for 30s — recovery counter reset',
);
}
});
} else {
_recoveryAttempts = 0;
}
_setStatus(StreamConnectionStatus.connected);
_emit(
StreamRecoveryResult<T>(
data: rows.map(_fromMap).toList(),
connectionStatus: StreamConnectionStatus.connected,
isStale: false,
),
);
}
void _onRealtimeError(Object error, [StackTrace? stack]) {
if (_disposed) return;
// Cancel any stability timer the connection is not stable.
_stabilityTimer?.cancel();
final isRateLimit = _isRateLimitError(error);
final isTimeout = _isTimeoutError(error);
final tag = isRateLimit
? ' (rate-limited)'
: isTimeout
? ' (timeout)'
: '';
debugPrint('StreamRecoveryWrapper[$channelName]: stream error$tag: $error');
_setStatus(StreamConnectionStatus.recovering);
if (_recoveryAttempts >= _config.maxRecoveryAttempts) {
if (_config.enablePollingFallback) {
_startPollingFallback();
} else {
_setStatus(StreamConnectionStatus.failed);
_emit(
StreamRecoveryResult<T>(
data: const [],
connectionStatus: StreamConnectionStatus.failed,
isStale: true,
error: error.toString(),
),
);
}
return;
}
_recoveryAttempts++;
// Compute backoff delay. Rate-limit errors get a longer floor (5 s).
final baseDelay =
_config.initialDelayMs *
math.pow(_config.backoffMultiplier, _recoveryAttempts - 1);
final effectiveDelay = isRateLimit
? math.max(baseDelay.toInt(), 5000)
: baseDelay.toInt();
final cappedDelay = math.min(effectiveDelay, _config.maxDelayMs);
debugPrint(
'StreamRecoveryWrapper[$channelName]: recovery attempt '
'$_recoveryAttempts/${_config.maxRecoveryAttempts}, '
'delay=${cappedDelay}ms$tag',
);
// Fire a single REST poll immediately so the UI can show fresh data
// under the skeleton shimmer while waiting for realtime to reconnect.
_pollOnce();
// Schedule re-subscription after backoff.
_recoveryTimer?.cancel();
_recoveryTimer = Timer(Duration(milliseconds: cappedDelay), () {
if (_disposed) return;
_listening = false;
_startRealtimeSubscription();
});
}
void _onRealtimeDone() {
if (_disposed) return;
debugPrint('StreamRecoveryWrapper[$channelName]: stream completed');
// Attempt to reconnect once if the stream closes unexpectedly.
if (_recoveryAttempts < _config.maxRecoveryAttempts) {
_onRealtimeError(StateError('realtime stream completed unexpectedly'));
}
}
// Polling fallback
void _startPollingFallback() {
_realtimeSub?.cancel();
_listening = false;
_setStatus(StreamConnectionStatus.polling);
_pollingTimer?.cancel();
_pollOnce(); // Immediate first poll
_pollingTimer = Timer.periodic(
Duration(milliseconds: _config.pollingIntervalMs),
(_) => _pollOnce(),
);
}
Future<void> _pollOnce() async {
if (_disposed) return;
try {
final data = await _onPollData();
_emit(
StreamRecoveryResult<T>(
data: data,
connectionStatus: _connectionStatus,
isStale: true,
),
);
} catch (e) {
debugPrint('StreamRecoveryWrapper[$channelName]: poll error: $e');
if (_connectionStatus == StreamConnectionStatus.polling) {
_setStatus(StreamConnectionStatus.stale);
_emit(
StreamRecoveryResult<T>(
data: const [],
connectionStatus: StreamConnectionStatus.stale,
isStale: true,
error: e.toString(),
),
);
}
}
}
// Error classification
/// Whether [error] indicates a Supabase channel rate limit.
static bool _isRateLimitError(Object error) {
final msg = error.toString().toLowerCase();
return msg.contains('rate limit') ||
msg.contains('rate_limit') ||
msg.contains('channelratelimitreached') ||
msg.contains('too many') ||
msg.contains('429');
}
/// Whether [error] indicates a subscription timeout.
static bool _isTimeoutError(Object error) {
if (error is TimeoutException) return true;
final msg = error.toString().toLowerCase();
return msg.contains('timeout') ||
msg.contains('timed out') ||
msg.contains('timed_out');
}
// Helpers
void _emit(StreamRecoveryResult<T> result) {
if (!_disposed && _controller != null && !_controller!.isClosed) {
_controller!.add(result);
}
}
/// Update connection status and notify the per-channel callback.
void _setStatus(StreamConnectionStatus status) {
if (_connectionStatus != status) {
_connectionStatus = status;
_onStatusChanged?.call(channelName, status);
}
}
/// Manually trigger a recovery attempt.
void retry() {
_recoveryAttempts = 0;
_pollingTimer?.cancel();
_recoveryTimer?.cancel();
_listening = false;
_startRealtimeSubscription();
}
/// Clean up all resources and notify the status callback that this
/// channel is no longer active, preventing ghost entries in the
/// [RealtimeController]'s recovering-channels set.
void dispose() {
if (_disposed) return;
_disposed = true;
_pollingTimer?.cancel();
_recoveryTimer?.cancel();
_stabilityTimer?.cancel();
_realtimeSub?.cancel();
// Ensure the channel is removed from the recovering set when the
// wrapper is torn down (e.g. provider disposed during navigation).
// Without this, disposed wrappers that were mid-recovery leave
// orphaned entries that keep the reconnection indicator spinning.
if (_connectionStatus != StreamConnectionStatus.connected) {
_onStatusChanged?.call(channelName, StreamConnectionStatus.connected);
}
_controller?.close();
}
}
/// Result of a stream emission, including metadata about connection status.
class StreamRecoveryResult<T> {
/// The data emitted by the stream.
final List<T> data;
/// Current connection status.
final StreamConnectionStatus connectionStatus;
/// Whether the data is stale (not live from realtime).
final bool isStale;
/// Error message, if any.
final String? error;
StreamRecoveryResult({
required this.data,
required this.connectionStatus,
required this.isStale,
this.error,
});
/// True if data is live and reliable.
bool get isLive => connectionStatus == StreamConnectionStatus.connected;
/// True if we should show a "data may be stale" indicator.
bool get shouldIndicateStale =>
isStale || connectionStatus == StreamConnectionStatus.polling;
}
File diff suppressed because it is too large Load Diff
+33 -10
View File
@@ -1,24 +1,47 @@
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'supabase_provider.dart'; import 'supabase_provider.dart';
import 'stream_recovery.dart';
import 'realtime_controller.dart';
import '../models/team.dart'; import '../models/team.dart';
import '../models/team_member.dart'; import '../models/team_member.dart';
/// Real-time stream of teams (keeps UI in sync with DB changes). /// Real-time stream of teams with automatic recovery and graceful degradation.
final teamsProvider = StreamProvider<List<Team>>((ref) { final teamsProvider = StreamProvider<List<Team>>((ref) {
final client = ref.watch(supabaseClientProvider); final client = ref.watch(supabaseClientProvider);
return client
.from('teams') final wrapper = StreamRecoveryWrapper<Team>(
.stream(primaryKey: ['id']) stream: client.from('teams').stream(primaryKey: ['id']).order('name'),
.order('name') onPollData: () async {
.map((rows) => rows.map((r) => Team.fromMap(r)).toList()); final data = await client.from('teams').select().order('name');
return data.map(Team.fromMap).toList();
},
fromMap: Team.fromMap,
channelName: 'teams',
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
);
ref.onDispose(wrapper.dispose);
return wrapper.stream.map((result) => result.data);
}); });
/// Real-time stream of team membership rows. /// Real-time stream of team membership rows with automatic recovery.
final teamMembersProvider = StreamProvider<List<TeamMember>>((ref) { final teamMembersProvider = StreamProvider<List<TeamMember>>((ref) {
final client = ref.watch(supabaseClientProvider); final client = ref.watch(supabaseClientProvider);
return client
final wrapper = StreamRecoveryWrapper<TeamMember>(
stream: client
.from('team_members') .from('team_members')
.stream(primaryKey: ['team_id', 'user_id']) .stream(primaryKey: ['team_id', 'user_id']),
.map((rows) => rows.map((r) => TeamMember.fromMap(r)).toList()); onPollData: () async {
final data = await client.from('team_members').select();
return data.map(TeamMember.fromMap).toList();
},
fromMap: TeamMember.fromMap,
channelName: 'team_members',
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
);
ref.onDispose(wrapper.dispose);
return wrapper.stream.map((result) => result.data);
}); });
+396 -52
View File
@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import '../models/office.dart'; import '../models/office.dart';
import '../models/ticket.dart'; import '../models/ticket.dart';
@@ -11,14 +12,25 @@ import 'profile_provider.dart';
import 'supabase_provider.dart'; import 'supabase_provider.dart';
import 'user_offices_provider.dart'; import 'user_offices_provider.dart';
import 'tasks_provider.dart'; import 'tasks_provider.dart';
import 'stream_recovery.dart';
import 'realtime_controller.dart';
final officesProvider = StreamProvider<List<Office>>((ref) { final officesProvider = StreamProvider<List<Office>>((ref) {
final client = ref.watch(supabaseClientProvider); final client = ref.watch(supabaseClientProvider);
return client
.from('offices') final wrapper = StreamRecoveryWrapper<Office>(
.stream(primaryKey: ['id']) stream: client.from('offices').stream(primaryKey: ['id']).order('name'),
.order('name') onPollData: () async {
.map((rows) => rows.map(Office.fromMap).toList()); final data = await client.from('offices').select().order('name');
return data.map(Office.fromMap).toList();
},
fromMap: Office.fromMap,
channelName: 'offices',
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
);
ref.onDispose(wrapper.dispose);
return wrapper.stream.map((result) => result.data);
}); });
final officesOnceProvider = FutureProvider<List<Office>>((ref) async { final officesOnceProvider = FutureProvider<List<Office>>((ref) async {
@@ -111,6 +123,46 @@ class TicketQuery {
} }
} }
/// Builds the isolate payload from a list of [Ticket] objects and the current
/// query/access context. Extracted so the initial REST seed and the realtime
/// stream listener can share the same logic without duplication.
Map<String, dynamic> _buildTicketPayload({
required List<Ticket> tickets,
required bool isGlobal,
required List<String> allowedOfficeIds,
required TicketQuery query,
}) {
final rowsList = tickets
.map(
(ticket) => <String, dynamic>{
'id': ticket.id,
'subject': ticket.subject,
'description': ticket.description,
'status': ticket.status,
'office_id': ticket.officeId,
'creator_id': ticket.creatorId,
'created_at': ticket.createdAt.toIso8601String(),
'responded_at': ticket.respondedAt?.toIso8601String(),
'promoted_at': ticket.promotedAt?.toIso8601String(),
'closed_at': ticket.closedAt?.toIso8601String(),
},
)
.toList();
return {
'rows': rowsList,
'isGlobal': isGlobal,
'allowedOfficeIds': allowedOfficeIds,
'offset': query.offset,
'limit': query.limit,
'searchQuery': query.searchQuery,
'officeId': query.officeId,
'status': query.status,
'dateStart': query.dateRange?.start.millisecondsSinceEpoch,
'dateEnd': query.dateRange?.end.millisecondsSinceEpoch,
};
}
final ticketsProvider = StreamProvider<List<Ticket>>((ref) { final ticketsProvider = StreamProvider<List<Ticket>>((ref) {
final client = ref.watch(supabaseClientProvider); final client = ref.watch(supabaseClientProvider);
final profileAsync = ref.watch(currentProfileProvider); final profileAsync = ref.watch(currentProfileProvider);
@@ -124,95 +176,305 @@ final ticketsProvider = StreamProvider<List<Ticket>>((ref) {
final isGlobal = final isGlobal =
profile.role == 'admin' || profile.role == 'admin' ||
profile.role == 'programmer' ||
profile.role == 'dispatcher' || profile.role == 'dispatcher' ||
profile.role == 'it_staff'; profile.role == 'it_staff';
// Use stream for realtime updates, then apply pagination & search filters final allowedOfficeIds =
// client-side because `.range(...)` is not supported on the stream builder.
final baseStream = client
.from('tickets')
.stream(primaryKey: ['id'])
.map((rows) => rows.map(Ticket.fromMap).toList());
return baseStream.map((allTickets) {
debugPrint('[ticketsProvider] stream event: ${allTickets.length} rows');
var list = allTickets;
if (!isGlobal) {
final officeIds =
assignmentsAsync.valueOrNull assignmentsAsync.valueOrNull
?.where((assignment) => assignment.userId == profile.id) ?.where((a) => a.userId == profile.id)
.map((assignment) => assignment.officeId) .map((a) => a.officeId)
.toSet()
.toList() ?? .toList() ??
<String>[]; <String>[];
if (officeIds.isEmpty) return <Ticket>[];
final allowedOffices = officeIds.toSet(); // Wrap realtime stream with recovery logic
list = list.where((t) => allowedOffices.contains(t.officeId)).toList(); final wrapper = StreamRecoveryWrapper<Ticket>(
stream: client.from('tickets').stream(primaryKey: ['id']),
onPollData: () async {
final data = await client.from('tickets').select();
return data.cast<Map<String, dynamic>>().map(Ticket.fromMap).toList();
},
fromMap: Ticket.fromMap,
channelName: 'tickets',
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
);
ref.onDispose(wrapper.dispose);
var lastResultHash = '';
Timer? debounceTimer;
// broadcast() so Riverpod and any other listener can both receive events.
final controller = StreamController<List<Ticket>>.broadcast();
void emitDebounced(List<Ticket> tickets) {
debounceTimer?.cancel();
debounceTimer = Timer(const Duration(milliseconds: 150), () {
if (!controller.isClosed) controller.add(tickets);
});
} }
if (query.officeId != null) { ref.onDispose(() {
list = list.where((t) => t.officeId == query.officeId).toList(); debounceTimer?.cancel();
controller.close();
});
// Immediate REST seed
// Fire a one-shot HTTP fetch right now so the UI can render before the
// WebSocket realtime channel is fully established. This eliminates the
// loading delay on web (WebSocket ~200-500 ms) and the initial flash on
// mobile. The realtime stream takes over afterwards; the hash check below
// prevents a duplicate rebuild if both arrive with identical data.
unawaited(
Future(() async {
try {
final data = await client.from('tickets').select();
final raw = data
.cast<Map<String, dynamic>>()
.map(Ticket.fromMap)
.toList();
final payload = _buildTicketPayload(
tickets: raw,
isGlobal: isGlobal,
allowedOfficeIds: allowedOfficeIds,
query: query,
);
final processed = await compute(_processTicketsInIsolate, payload);
final tickets = (processed as List<dynamic>)
.cast<Map<String, dynamic>>()
.map(Ticket.fromMap)
.toList();
final hash = tickets.fold('', (h, t) => '$h${t.id}');
if (!controller.isClosed && hash != lastResultHash) {
lastResultHash = hash;
controller.add(tickets); // emit immediately no debounce
} }
if (query.status != null) { } catch (e) {
list = list.where((t) => t.status == query.status).toList(); debugPrint('[ticketsProvider] initial seed error: $e');
}
}),
);
// Realtime stream
// Processes every realtime event through the same isolate. Debounced so
// rapid consecutive events (e.g. bulk inserts) don't cause repeated renders.
final wrapperSub = wrapper.stream
.asyncMap((result) async {
final payload = _buildTicketPayload(
tickets: result.data,
isGlobal: isGlobal,
allowedOfficeIds: allowedOfficeIds,
query: query,
);
final processed = await compute(_processTicketsInIsolate, payload);
return (processed as List<dynamic>)
.cast<Map<String, dynamic>>()
.map(Ticket.fromMap)
.toList();
})
.listen(
(tickets) {
final hash = tickets.fold('', (h, t) => '$h${t.id}');
if (hash != lastResultHash) {
lastResultHash = hash;
emitDebounced(tickets);
}
},
onError: (Object e) {
debugPrint('[ticketsProvider] stream error: $e');
// Don't forward errors — the wrapper handles recovery internally.
},
);
ref.onDispose(wrapperSub.cancel);
return controller.stream;
});
// Runs inside a background isolate. Accepts a serializable payload and
// returns a list of ticket maps after filtering/sorting/pagination.
List<Map<String, dynamic>> _processTicketsInIsolate(
Map<String, dynamic> payload,
) {
final rows = (payload['rows'] as List).cast<Map<String, dynamic>>();
var list = List<Map<String, dynamic>>.from(rows);
final isGlobal = payload['isGlobal'] as bool? ?? false;
final allowedOfficeIds =
(payload['allowedOfficeIds'] as List?)?.cast<String>().toSet() ??
<String>{};
if (!isGlobal) {
if (allowedOfficeIds.isEmpty) {
return <Map<String, dynamic>>[];
} }
if (query.searchQuery.isNotEmpty) {
final q = query.searchQuery.toLowerCase();
list = list list = list
.where( .where((t) => allowedOfficeIds.contains(t['office_id']))
(t) =>
t.subject.toLowerCase().contains(q) ||
t.description.toLowerCase().contains(q),
)
.toList(); .toList();
} }
// Sort: newest first final officeId = payload['officeId'] as String?;
list.sort((a, b) => b.createdAt.compareTo(a.createdAt)); if (officeId != null) {
list = list.where((t) => t['office_id'] == officeId).toList();
}
final status = payload['status'] as String?;
if (status != null) {
list = list.where((t) => t['status'] == status).toList();
}
// Pagination final searchQuery = (payload['searchQuery'] as String?) ?? '';
final start = query.offset; if (searchQuery.isNotEmpty) {
final end = (start + query.limit).clamp(0, list.length); final q = searchQuery.toLowerCase();
if (start >= list.length) return <Ticket>[]; list = list.where((t) {
return list.sublist(start, end); final subj = (t['subject'] as String?)?.toLowerCase() ?? '';
final desc = (t['description'] as String?)?.toLowerCase() ?? '';
return subj.contains(q) || desc.contains(q);
}).toList();
}
// Parse created_at timestamp from map. `created_at` may be ISO strings or timestamps.
int parseCreatedAt(Map<String, dynamic> m) {
final v = m['created_at'];
if (v == null) return 0;
if (v is int) return v;
if (v is double) return v.toInt();
if (v is String) {
try {
return DateTime.parse(v).millisecondsSinceEpoch;
} catch (_) {
return 0;
}
}
return 0;
}
int statusRank(String s) {
switch (s) {
case 'pending':
return 0;
case 'promoted':
return 1;
case 'closed':
return 2;
default:
return 3;
}
}
// Sort by status rank first (pending promoted closed),
// then by created_at descending (newest first)
list.sort((a, b) {
final ra = statusRank((a['status'] as String?) ?? '');
final rb = statusRank((b['status'] as String?) ?? '');
final rcmp = ra.compareTo(rb);
if (rcmp != 0) return rcmp;
// Same status: sort by created_at descending (newest first)
return parseCreatedAt(b).compareTo(parseCreatedAt(a));
}); });
});
final start = (payload['offset'] as int?) ?? 0;
final limit = (payload['limit'] as int?) ?? 50;
final end = (start + limit).clamp(0, list.length);
if (start >= list.length) return <Map<String, dynamic>>[];
return list.sublist(start, end);
}
/// Provider for ticket query parameters. /// Provider for ticket query parameters.
final ticketsQueryProvider = StateProvider<TicketQuery>( final ticketsQueryProvider = StateProvider<TicketQuery>(
(ref) => const TicketQuery(), (ref) => const TicketQuery(),
); );
/// Derived provider that selects a single [Ticket] by ID from the tickets list.
///
/// Because [Ticket] implements `==`, this provider only notifies watchers when
/// the specific ticket's data actually changes — not when unrelated tickets in
/// the list are updated. Use this in detail screens to avoid full-list rebuilds.
final ticketByIdProvider = Provider.family<Ticket?, String>((ref, ticketId) {
return ref
.watch(ticketsProvider)
.valueOrNull
?.where((t) => t.id == ticketId)
.firstOrNull;
});
final ticketMessagesProvider = final ticketMessagesProvider =
StreamProvider.family<List<TicketMessage>, String>((ref, ticketId) { StreamProvider.family<List<TicketMessage>, String>((ref, ticketId) {
final client = ref.watch(supabaseClientProvider); final client = ref.watch(supabaseClientProvider);
return client
final wrapper = StreamRecoveryWrapper<TicketMessage>(
stream: client
.from('ticket_messages') .from('ticket_messages')
.stream(primaryKey: ['id']) .stream(primaryKey: ['id'])
.eq('ticket_id', ticketId) .eq('ticket_id', ticketId)
.order('created_at', ascending: false) .order('created_at', ascending: false),
.map((rows) => rows.map(TicketMessage.fromMap).toList()); onPollData: () async {
final data = await client
.from('ticket_messages')
.select()
.eq('ticket_id', ticketId)
.order('created_at', ascending: false);
return data.map(TicketMessage.fromMap).toList();
},
fromMap: TicketMessage.fromMap,
channelName: 'ticket_messages:$ticketId',
onStatusChanged: ref
.read(realtimeControllerProvider)
.handleChannelStatus,
);
ref.onDispose(wrapper.dispose);
return wrapper.stream.map((result) => result.data);
}); });
final ticketMessagesAllProvider = StreamProvider<List<TicketMessage>>((ref) { final ticketMessagesAllProvider = StreamProvider<List<TicketMessage>>((ref) {
final client = ref.watch(supabaseClientProvider); final client = ref.watch(supabaseClientProvider);
return client
final wrapper = StreamRecoveryWrapper<TicketMessage>(
stream: client
.from('ticket_messages') .from('ticket_messages')
.stream(primaryKey: ['id']) .stream(primaryKey: ['id'])
.order('created_at', ascending: false) .order('created_at', ascending: false),
.map((rows) => rows.map(TicketMessage.fromMap).toList()); onPollData: () async {
final data = await client
.from('ticket_messages')
.select()
.order('created_at', ascending: false);
return data.map(TicketMessage.fromMap).toList();
},
fromMap: TicketMessage.fromMap,
channelName: 'ticket_messages_all',
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
);
ref.onDispose(wrapper.dispose);
return wrapper.stream.map((result) => result.data);
}); });
final taskMessagesProvider = StreamProvider.family<List<TicketMessage>, String>( final taskMessagesProvider = StreamProvider.family<List<TicketMessage>, String>(
(ref, taskId) { (ref, taskId) {
final client = ref.watch(supabaseClientProvider); final client = ref.watch(supabaseClientProvider);
return client
final wrapper = StreamRecoveryWrapper<TicketMessage>(
stream: client
.from('ticket_messages') .from('ticket_messages')
.stream(primaryKey: ['id']) .stream(primaryKey: ['id'])
.eq('task_id', taskId) .eq('task_id', taskId)
.order('created_at', ascending: false) .order('created_at', ascending: false),
.map((rows) => rows.map(TicketMessage.fromMap).toList()); onPollData: () async {
final data = await client
.from('ticket_messages')
.select()
.eq('task_id', taskId)
.order('created_at', ascending: false);
return data.map(TicketMessage.fromMap).toList();
},
fromMap: TicketMessage.fromMap,
channelName: 'task_messages:$taskId',
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
);
ref.onDispose(wrapper.dispose);
return wrapper.stream.map((result) => result.data);
}, },
); );
@@ -268,6 +530,62 @@ class TicketsController {
) )
.toList(); .toList();
await _client.from('notifications').insert(rows); await _client.from('notifications').insert(rows);
// Send FCM pushes for ticket creation
try {
String actorName = 'Someone';
if (actorId != null && actorId.isNotEmpty) {
try {
final p = await _client
.from('profiles')
.select('full_name,display_name,name')
.eq('id', actorId)
.maybeSingle();
if (p != null) {
if (p['full_name'] != null) {
actorName = p['full_name'].toString();
} else if (p['display_name'] != null) {
actorName = p['display_name'].toString();
} else if (p['name'] != null) {
actorName = p['name'].toString();
}
}
} catch (_) {}
}
String? ticketNumber;
try {
final t = await _client
.from('tickets')
.select('ticket_number')
.eq('id', ticketId)
.maybeSingle();
if (t != null && t['ticket_number'] != null) {
ticketNumber = t['ticket_number'].toString();
}
} catch (_) {}
final title = '$actorName created a new ticket';
final body = ticketNumber != null
? '$actorName created ticket #$ticketNumber'
: '$actorName created a new ticket';
await _client.functions.invoke(
'send_fcm',
body: {
'user_ids': recipients,
'title': title,
'body': body,
'data': {
'ticket_id': ticketId,
'ticket_number': ?ticketNumber,
'type': 'created',
},
},
);
} catch (e) {
// non-fatal
debugPrint('ticket notifyCreated push error: $e');
}
} catch (_) { } catch (_) {
return; return;
} }
@@ -370,6 +688,32 @@ class TicketsController {
} }
} }
} }
/// Update editable ticket fields such as subject, description, and office.
Future<void> updateTicket({
required String ticketId,
String? subject,
String? description,
String? officeId,
}) async {
final payload = <String, dynamic>{};
if (subject != null) payload['subject'] = subject;
if (description != null) payload['description'] = description;
if (officeId != null) payload['office_id'] = officeId;
if (payload.isEmpty) return;
await _client.from('tickets').update(payload).eq('id', ticketId);
// record an activity row for edit operations (best-effort)
try {
final actorId = _client.auth.currentUser?.id;
await _client.from('ticket_messages').insert({
'ticket_id': ticketId,
'sender_id': actorId,
'content': 'Ticket updated',
});
} catch (_) {}
}
} }
class OfficesController { class OfficesController {
+106 -93
View File
@@ -30,14 +30,14 @@ class TypingIndicatorState {
} }
} }
final typingIndicatorProvider = StateNotifierProvider.autoDispose final typingIndicatorProvider =
.family<TypingIndicatorController, TypingIndicatorState, String>(( StateNotifierProvider.family<
ref, TypingIndicatorController,
ticketId, TypingIndicatorState,
) { String
>((ref, ticketId) {
final client = ref.watch(supabaseClientProvider); final client = ref.watch(supabaseClientProvider);
final controller = TypingIndicatorController(client, ticketId); final controller = TypingIndicatorController(client, ticketId);
ref.onDispose(controller.dispose);
return controller; return controller;
}); });
@@ -66,30 +66,14 @@ class TypingIndicatorController extends StateNotifier<TypingIndicatorState> {
channel.onBroadcast( channel.onBroadcast(
event: 'typing', event: 'typing',
callback: (payload) { callback: (payload) {
// Prevent any work if we're already disposing. Log stack for diagnostics. try {
if (_disposed || !mounted) { if (_disposed || !mounted) return;
if (kDebugMode) {
debugPrint(
'TypingIndicatorController: onBroadcast skipped (disposed|unmounted)',
);
debugPrint(StackTrace.current.toString());
}
return;
}
final Map<String, dynamic> data = _extractPayload(payload); final Map<String, dynamic> data = _extractPayload(payload);
final userId = data['user_id'] as String?; final userId = data['user_id'] as String?;
final rawType = data['type']?.toString(); final rawType = data['type']?.toString();
final currentUserId = _client.auth.currentUser?.id; final currentUserId = _client.auth.currentUser?.id;
if (_disposed || !mounted) { if (_disposed || !mounted) return;
if (kDebugMode) {
debugPrint(
'TypingIndicatorController: payload received but controller disposed/unmounted',
);
debugPrint(StackTrace.current.toString());
}
return;
}
state = state.copyWith(lastPayload: data); state = state.copyWith(lastPayload: data);
if (userId == null || userId == currentUserId) { if (userId == null || userId == currentUserId) {
return; return;
@@ -99,112 +83,141 @@ class TypingIndicatorController extends StateNotifier<TypingIndicatorState> {
return; return;
} }
_markRemoteTyping(userId); _markRemoteTyping(userId);
} catch (e, st) {
debugPrint(
'TypingIndicatorController: broadcast callback error: $e\n$st',
);
}
}, },
); );
channel.subscribe((status, error) { channel.subscribe((status, error) {
if (_disposed || !mounted) { try {
if (kDebugMode) { if (_disposed || !mounted) return;
debugPrint(
'TypingIndicatorController: subscribe callback skipped (disposed|unmounted)',
);
debugPrint(StackTrace.current.toString());
}
return;
}
state = state.copyWith(channelStatus: status.name); state = state.copyWith(channelStatus: status.name);
if (error != null) {
debugPrint('TypingIndicatorController: subscribe error: $error');
}
} catch (e, st) {
debugPrint(
'TypingIndicatorController: subscribe callback error: $e\n$st',
);
}
}); });
_channel = channel; _channel = channel;
} }
Map<String, dynamic> _extractPayload(dynamic payload) { Map<String, dynamic> _extractPayload(dynamic payload) {
if (payload is Map<String, dynamic>) { // The realtime client can wrap the actual broadcast payload inside
final inner = payload['payload']; // several nested fields (e.g. {payload: {payload: {...}}}). Walk the
if (inner is Map<String, dynamic>) { // object until we find a map containing `user_id` or `type` keys which
return inner; // represent the actual typing payload.
try {
dynamic current = payload;
for (var i = 0; i < 6; i++) {
if (current is Map<String, dynamic>) {
// Only return when we actually find the `user_id`, otherwise try
// to unwrap nested envelopes. Some wrappers include `type: broadcast`
// at the top-level which should not be treated as the message.
if (current.containsKey('user_id')) {
return Map<String, dynamic>.from(current);
} }
return payload; if (current.containsKey('payload') &&
current['payload'] is Map<String, dynamic>) {
current = current['payload'];
continue;
} }
final dynamic inner = payload.payload; // Some realtime envelope stores the payload at `data`.
if (inner is Map<String, dynamic>) { if (current.containsKey('data') &&
return inner; current['data'] is Map<String, dynamic>) {
current = current['data'];
continue;
} }
break;
}
// Try common field on wrapper objects (e.g. RealtimeMessage.payload)
try {
final dyn = (current as dynamic).payload;
if (dyn is Map<String, dynamic>) {
current = dyn;
continue;
}
} catch (_) {
break;
}
}
} catch (_) {}
// As a last-resort, do a shallow recursive search for a map containing
// `user_id` in case the realtime client used a Map<dynamic,dynamic>
// shape that wasn't caught above.
try {
Map<String, dynamic>? found;
void search(dynamic node, int depth) {
if (found != null || depth > 4) return;
if (node is Map) {
try {
final m = Map<String, dynamic>.from(node);
if (m.containsKey('user_id')) {
found = m;
return;
}
for (final v in m.values) {
search(v, depth + 1);
if (found != null) return;
}
} catch (_) {
// ignore conversion errors
}
} else if (node is Iterable) {
for (final v in node) {
search(v, depth + 1);
if (found != null) return;
}
}
}
search(payload, 0);
if (found != null) return found!;
} catch (_) {}
return <String, dynamic>{}; return <String, dynamic>{};
} }
void userTyping() { void userTyping() {
if (_disposed || !mounted) { if (_disposed || !mounted) return;
if (kDebugMode) {
debugPrint(
'TypingIndicatorController.userTyping() ignored after dispose',
);
}
return;
}
if (_client.auth.currentUser?.id == null) return; if (_client.auth.currentUser?.id == null) return;
_sendTypingEvent('start'); _sendTypingEvent('start');
_typingTimer?.cancel(); _typingTimer?.cancel();
_typingTimer = Timer(const Duration(milliseconds: 150), () { // Debounce sending the stop event slightly so quick pauses don't spam
if (_disposed || !mounted) { // the network. 150ms was short and caused frequent start/stop bursts;
if (kDebugMode) { // increase to 600ms to stabilize UX.
debugPrint( _typingTimer = Timer(const Duration(milliseconds: 600), () {
'TypingIndicatorController._typingTimer callback ignored after dispose', if (_disposed || !mounted) return;
);
}
return;
}
_sendTypingEvent('stop'); _sendTypingEvent('stop');
}); });
} }
void stopTyping() { void stopTyping() {
if (_disposed || !mounted) { if (_disposed || !mounted) return;
if (kDebugMode) {
debugPrint(
'TypingIndicatorController.stopTyping() ignored after dispose',
);
}
return;
}
_typingTimer?.cancel(); _typingTimer?.cancel();
_sendTypingEvent('stop'); _sendTypingEvent('stop');
} }
void _markRemoteTyping(String userId) { void _markRemoteTyping(String userId) {
if (_disposed || !mounted) { if (_disposed || !mounted) return;
if (kDebugMode) {
debugPrint(
'TypingIndicatorController._markRemoteTyping ignored after dispose for user: $userId',
);
debugPrint(StackTrace.current.toString());
}
return;
}
final updated = {...state.userIds, userId}; final updated = {...state.userIds, userId};
if (_disposed || !mounted) return; if (_disposed || !mounted) return;
state = state.copyWith(userIds: updated); state = state.copyWith(userIds: updated);
_remoteTimeouts[userId]?.cancel(); _remoteTimeouts[userId]?.cancel();
_remoteTimeouts[userId] = Timer(const Duration(milliseconds: 400), () { // Extend timeout to 2500ms to accommodate brief realtime interruptions
if (_disposed || !mounted) { // (auth refresh, channel reconnect, message processing, etc.) without
if (kDebugMode) { // clearing the presence. This gives a smoother typing experience.
debugPrint( _remoteTimeouts[userId] = Timer(const Duration(milliseconds: 3500), () {
'TypingIndicatorController.remote timeout callback ignored after dispose for user: $userId', if (_disposed || !mounted) return;
);
}
return;
}
_clearRemoteTyping(userId); _clearRemoteTyping(userId);
}); });
} }
void _clearRemoteTyping(String userId) { void _clearRemoteTyping(String userId) {
if (_disposed || !mounted) { if (_disposed || !mounted) return;
if (kDebugMode) {
debugPrint(
'TypingIndicatorController._clearRemoteTyping ignored after dispose for user: $userId',
);
}
return;
}
final updated = {...state.userIds}..remove(userId); final updated = {...state.userIds}..remove(userId);
if (_disposed || !mounted) return; if (_disposed || !mounted) return;
state = state.copyWith(userIds: updated); state = state.copyWith(userIds: updated);
+20 -3
View File
@@ -3,14 +3,31 @@ import 'package:supabase_flutter/supabase_flutter.dart';
import '../models/user_office.dart'; import '../models/user_office.dart';
import 'supabase_provider.dart'; import 'supabase_provider.dart';
import 'stream_recovery.dart';
import 'realtime_controller.dart';
final userOfficesProvider = StreamProvider<List<UserOffice>>((ref) { final userOfficesProvider = StreamProvider<List<UserOffice>>((ref) {
final client = ref.watch(supabaseClientProvider); final client = ref.watch(supabaseClientProvider);
return client
final wrapper = StreamRecoveryWrapper<UserOffice>(
stream: client
.from('user_offices') .from('user_offices')
.stream(primaryKey: ['user_id', 'office_id']) .stream(primaryKey: ['user_id', 'office_id'])
.order('created_at') .order('created_at'),
.map((rows) => rows.map(UserOffice.fromMap).toList()); onPollData: () async {
final data = await client
.from('user_offices')
.select()
.order('created_at');
return data.map(UserOffice.fromMap).toList();
},
fromMap: UserOffice.fromMap,
channelName: 'user_offices',
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
);
ref.onDispose(wrapper.dispose);
return wrapper.stream.map((result) => result.data);
}); });
final userOfficesControllerProvider = Provider<UserOfficesController>((ref) { final userOfficesControllerProvider = Provider<UserOfficesController>((ref) {
@@ -0,0 +1,124 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../models/verification_session.dart';
import 'supabase_provider.dart';
/// Provider for the verification session controller.
final verificationSessionControllerProvider =
Provider<VerificationSessionController>((ref) {
final client = ref.watch(supabaseClientProvider);
return VerificationSessionController(client);
});
/// Controller for creating, completing, and listening to verification sessions.
class VerificationSessionController {
VerificationSessionController(this._client);
final SupabaseClient _client;
/// Create a new verification session and return it.
Future<VerificationSession> createSession({
required String type,
String? contextId,
}) async {
final userId = _client.auth.currentUser!.id;
final data = await _client
.from('verification_sessions')
.insert({'user_id': userId, 'type': type, 'context_id': contextId})
.select()
.single();
return VerificationSession.fromMap(data);
}
/// Fetch a session by ID.
Future<VerificationSession?> getSession(String sessionId) async {
final data = await _client
.from('verification_sessions')
.select()
.eq('id', sessionId)
.maybeSingle();
return data == null ? null : VerificationSession.fromMap(data);
}
/// Listen for realtime changes on a specific session (used by web to detect
/// when mobile completes the verification).
Stream<VerificationSession> watchSession(String sessionId) {
return _client
.from('verification_sessions')
.stream(primaryKey: ['id'])
.eq('id', sessionId)
.map(
(rows) => rows.isEmpty
? throw Exception('Session not found')
: VerificationSession.fromMap(rows.first),
);
}
/// Complete a session: upload the face photo and mark the session as
/// completed. Called from the mobile verification screen.
Future<void> completeSession({
required String sessionId,
required Uint8List bytes,
required String fileName,
}) async {
final userId = _client.auth.currentUser!.id;
final ext = fileName.split('.').last.toLowerCase();
final path = '$userId/$sessionId.$ext';
// Upload to face-enrollment bucket (same bucket used for face photos)
await _client.storage
.from('face-enrollment')
.uploadBinary(
path,
bytes,
fileOptions: const FileOptions(upsert: true),
);
final url = _client.storage.from('face-enrollment').getPublicUrl(path);
// Mark session completed with the image URL
await _client
.from('verification_sessions')
.update({'status': 'completed', 'image_url': url})
.eq('id', sessionId);
}
/// After a session completes, apply the result based on the session type.
/// For 'enrollment': update the user's face photo.
/// For 'verification': update the attendance log.
Future<void> applySessionResult(VerificationSession session) async {
if (session.imageUrl == null) return;
if (session.type == 'enrollment') {
// Update profile face photo
await _client
.from('profiles')
.update({
'face_photo_url': session.imageUrl,
'face_enrolled_at': DateTime.now().toUtc().toIso8601String(),
})
.eq('id', session.userId);
} else if (session.type == 'verification' && session.contextId != null) {
// Update attendance log verification status.
// Cross-device verification defaults to check-in photo column.
await _client
.from('attendance_logs')
.update({
'verification_status': 'verified',
'check_in_verification_photo_url': session.imageUrl,
})
.eq('id', session.contextId!);
}
}
/// Expire a session (called when dialog is closed prematurely).
Future<void> expireSession(String sessionId) async {
await _client
.from('verification_sessions')
.update({'status': 'expired'})
.eq('id', sessionId);
}
}
+194
View File
@@ -0,0 +1,194 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:geolocator/geolocator.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../models/live_position.dart';
import '../services/background_location_service.dart';
import '../utils/location_permission.dart';
import 'profile_provider.dart';
import 'supabase_provider.dart';
import 'stream_recovery.dart';
import 'realtime_controller.dart';
/// All live positions of tracked users.
final livePositionsProvider = StreamProvider<List<LivePosition>>((ref) {
final client = ref.watch(supabaseClientProvider);
final wrapper = StreamRecoveryWrapper<LivePosition>(
stream: client.from('live_positions').stream(primaryKey: ['user_id']),
onPollData: () async {
final data = await client.from('live_positions').select();
return data.map(LivePosition.fromMap).toList();
},
fromMap: LivePosition.fromMap,
channelName: 'live_positions',
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
);
ref.onDispose(wrapper.dispose);
return wrapper.stream.map((result) => result.data);
});
final whereaboutsControllerProvider = Provider<WhereaboutsController>((ref) {
final client = ref.watch(supabaseClientProvider);
return WhereaboutsController(client);
});
class WhereaboutsController {
WhereaboutsController(this._client);
final SupabaseClient _client;
/// Upsert current position. Returns `in_premise` status.
Future<bool> updatePosition(double lat, double lng) async {
final data = await _client.rpc(
'update_live_position',
params: {'p_lat': lat, 'p_lng': lng},
);
return data as bool? ?? false;
}
/// Public convenience: fetch device position and upsert to live_positions.
/// Call after check-in / check-out to ensure immediate data freshness.
Future<void> updatePositionNow() => _updatePositionNow();
/// Toggle allow_tracking preference.
///
/// When enabling, requests location permission and immediately saves the
/// current position to `live_positions`. When disabling, updates position
/// one last time (so the final location is recorded) then removes the entry.
Future<void> setTracking(bool allow) async {
final userId = _client.auth.currentUser?.id;
if (userId == null) throw Exception('Not authenticated');
if (allow) {
// Ensure foreground + background location permission before enabling
final granted = await ensureBackgroundLocationPermission();
if (!granted) {
throw Exception(
'Background location permission is required for tracking',
);
}
}
await _client
.from('profiles')
.update({'allow_tracking': allow})
.eq('id', userId);
// Start or stop background location updates
if (allow) {
await startBackgroundLocationUpdates();
// Immediately save current position
await _updatePositionNow();
} else {
// Record final position before removing
await _updatePositionNow();
await stopBackgroundLocationUpdates();
// Remove the live position entry
await _client.from('live_positions').delete().eq('user_id', userId);
}
}
/// Immediately fetches current position and upserts into live_positions.
Future<void> _updatePositionNow() async {
try {
final serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) return;
var permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied ||
permission == LocationPermission.deniedForever) {
return;
}
final position = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.high,
),
);
await _client.rpc(
'update_live_position',
params: {'p_lat': position.latitude, 'p_lng': position.longitude},
);
} catch (_) {
// Best-effort; don't block the toggle action
}
}
}
/// Background location reporting service.
/// Starts a 1-minute periodic timer that reports position to the server.
final locationReportingProvider =
Provider.autoDispose<LocationReportingService>((ref) {
final client = ref.watch(supabaseClientProvider);
final profileAsync = ref.watch(currentProfileProvider);
final profile = profileAsync.valueOrNull;
final service = LocationReportingService(client);
// Auto-start if user has tracking enabled
if (profile != null && profile.allowTracking) {
service.start();
// Also ensure background task is registered
startBackgroundLocationUpdates();
}
ref.onDispose(service.stop);
return service;
});
class LocationReportingService {
LocationReportingService(this._client);
final SupabaseClient _client;
Timer? _timer;
bool _isRunning = false;
bool get isRunning => _isRunning;
void start() {
if (_isRunning) return;
_isRunning = true;
// Report immediately, then every 60 seconds
_reportPosition();
_timer = Timer.periodic(const Duration(seconds: 60), (_) {
_reportPosition();
});
}
void stop() {
_isRunning = false;
_timer?.cancel();
_timer = null;
}
Future<void> _reportPosition() async {
try {
final serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) return;
var permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied ||
permission == LocationPermission.deniedForever) {
return;
}
final position = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.high,
),
);
await _client.rpc(
'update_live_position',
params: {'p_lat': position.latitude, 'p_lng': position.longitude},
);
} catch (_) {
// Silently ignore errors in background reporting
}
}
}
+95 -34
View File
@@ -6,6 +6,8 @@ import '../models/duty_schedule.dart';
import '../models/swap_request.dart'; import '../models/swap_request.dart';
import 'profile_provider.dart'; import 'profile_provider.dart';
import 'supabase_provider.dart'; import 'supabase_provider.dart';
import 'stream_recovery.dart';
import 'realtime_controller.dart';
final geofenceProvider = FutureProvider<GeofenceConfig?>((ref) async { final geofenceProvider = FutureProvider<GeofenceConfig?>((ref) async {
final client = ref.watch(supabaseClientProvider); final client = ref.watch(supabaseClientProvider);
@@ -19,25 +21,39 @@ final geofenceProvider = FutureProvider<GeofenceConfig?>((ref) async {
return GeofenceConfig.fromJson(setting.value); return GeofenceConfig.fromJson(setting.value);
}); });
/// Toggle to show/hide past schedules. Defaults to false (hide past).
final showPastSchedulesProvider = StateProvider<bool>((ref) => false);
final dutySchedulesProvider = StreamProvider<List<DutySchedule>>((ref) { final dutySchedulesProvider = StreamProvider<List<DutySchedule>>((ref) {
final client = ref.watch(supabaseClientProvider); final client = ref.watch(supabaseClientProvider);
final profileAsync = ref.watch(currentProfileProvider); // Only recreate stream when user id changes (not on other profile edits).
final profile = profileAsync.valueOrNull; final profileId = ref.watch(
if (profile == null) { currentProfileProvider.select((p) => p.valueOrNull?.id),
);
if (profileId == null) {
return Stream.value(const <DutySchedule>[]); return Stream.value(const <DutySchedule>[]);
} }
final isAdmin = profile.role == 'admin' || profile.role == 'dispatcher'; // All roles now see all schedules (RLS updated in migration)
final base = client.from('duty_schedules').stream(primaryKey: ['id']); final wrapper = StreamRecoveryWrapper<DutySchedule>(
if (isAdmin) { stream: client
return base .from('duty_schedules')
.order('start_time') .stream(primaryKey: ['id'])
.map((rows) => rows.map(DutySchedule.fromMap).toList()); .order('start_time'),
} onPollData: () async {
return base final data = await client
.eq('user_id', profile.id) .from('duty_schedules')
.order('start_time') .select()
.map((rows) => rows.map(DutySchedule.fromMap).toList()); .order('start_time');
return data.map(DutySchedule.fromMap).toList();
},
fromMap: DutySchedule.fromMap,
channelName: 'duty_schedules',
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
);
ref.onDispose(wrapper.dispose);
return wrapper.stream.map((result) => result.data);
}); });
/// Fetch duty schedules by a list of IDs (used by UI when swap requests reference /// Fetch duty schedules by a list of IDs (used by UI when swap requests reference
@@ -81,31 +97,58 @@ final dutySchedulesForUserProvider =
final swapRequestsProvider = StreamProvider<List<SwapRequest>>((ref) { final swapRequestsProvider = StreamProvider<List<SwapRequest>>((ref) {
final client = ref.watch(supabaseClientProvider); final client = ref.watch(supabaseClientProvider);
final profileAsync = ref.watch(currentProfileProvider); // Only recreate stream when user id or role changes.
final profile = profileAsync.valueOrNull; final profileId = ref.watch(
if (profile == null) { currentProfileProvider.select((p) => p.valueOrNull?.id),
);
final profileRole = ref.watch(
currentProfileProvider.select((p) => p.valueOrNull?.role),
);
if (profileId == null) {
return Stream.value(const <SwapRequest>[]); return Stream.value(const <SwapRequest>[]);
} }
final isAdmin = profile.role == 'admin' || profile.role == 'dispatcher'; final isAdmin =
final base = client.from('swap_requests').stream(primaryKey: ['id']); profileRole == 'admin' ||
if (isAdmin) { profileRole == 'programmer' ||
return base profileRole == 'dispatcher';
final wrapper = StreamRecoveryWrapper<SwapRequest>(
stream: isAdmin
? client
.from('swap_requests')
.stream(primaryKey: ['id'])
.order('created_at', ascending: false) .order('created_at', ascending: false)
.map((rows) => rows.map(SwapRequest.fromMap).toList()); : client
} .from('swap_requests')
return base .stream(primaryKey: ['id'])
.order('created_at', ascending: false) .order('created_at', ascending: false),
.map( onPollData: () async {
(rows) => rows final data = await client
.where( .from('swap_requests')
(row) => .select()
row['requester_id'] == profile.id || .order('created_at', ascending: false);
row['recipient_id'] == profile.id, return data.map(SwapRequest.fromMap).toList();
) },
.map(SwapRequest.fromMap) fromMap: SwapRequest.fromMap,
.toList(), channelName: 'swap_requests',
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
); );
ref.onDispose(wrapper.dispose);
return wrapper.stream.map((result) {
// only return requests that are still actionable; once a swap has been
// accepted or rejected we no longer need to bubble it up to the UI for
// either party. admins still see "admin_review" rows so they can act on
// escalated cases.
return result.data.where((row) {
if (!(row.requesterId == profileId || row.recipientId == profileId)) {
return false;
}
// only keep pending and admin_review statuses
return row.status == 'pending' || row.status == 'admin_review';
}).toList();
});
}); });
final workforceControllerProvider = Provider<WorkforceController>((ref) { final workforceControllerProvider = Provider<WorkforceController>((ref) {
@@ -191,6 +234,24 @@ class WorkforceController {
.eq('id', swapId); .eq('id', swapId);
} }
Future<void> updateSchedule({
required String scheduleId,
required String userId,
required String shiftType,
required DateTime startTime,
required DateTime endTime,
}) async {
await _client
.from('duty_schedules')
.update({
'user_id': userId,
'shift_type': shiftType,
'start_time': startTime.toUtc().toIso8601String(),
'end_time': endTime.toUtc().toIso8601String(),
})
.eq('id', scheduleId);
}
String _formatDate(DateTime value) { String _formatDate(DateTime value) {
final date = DateTime(value.year, value.month, value.day); final date = DateTime(value.year, value.month, value.day);
final month = date.month.toString().padLeft(2, '0'); final month = date.month.toString().padLeft(2, '0');
+177 -43
View File
@@ -11,41 +11,61 @@ import '../screens/auth/signup_screen.dart';
import '../screens/admin/offices_screen.dart'; import '../screens/admin/offices_screen.dart';
import '../screens/admin/user_management_screen.dart'; import '../screens/admin/user_management_screen.dart';
import '../screens/admin/geofence_test_screen.dart'; import '../screens/admin/geofence_test_screen.dart';
import '../screens/admin/app_update_screen.dart';
import '../screens/dashboard/dashboard_screen.dart'; import '../screens/dashboard/dashboard_screen.dart';
import '../screens/notifications/notifications_screen.dart'; import '../screens/notifications/notifications_screen.dart';
import '../screens/profile/profile_screen.dart'; import '../screens/profile/profile_screen.dart';
import '../screens/shared/mobile_verification_screen.dart';
import '../screens/shared/under_development_screen.dart'; import '../screens/shared/under_development_screen.dart';
import '../screens/shared/permissions_screen.dart';
import '../screens/reports/reports_screen.dart';
import '../screens/tasks/task_detail_screen.dart'; import '../screens/tasks/task_detail_screen.dart';
import '../screens/tasks/tasks_list_screen.dart'; import '../screens/tasks/tasks_list_screen.dart';
import '../screens/tickets/ticket_detail_screen.dart'; import '../screens/tickets/ticket_detail_screen.dart';
import '../screens/tickets/tickets_list_screen.dart'; import '../screens/tickets/tickets_list_screen.dart';
import '../screens/workforce/workforce_screen.dart'; import '../screens/workforce/workforce_screen.dart';
import '../screens/attendance/attendance_screen.dart';
import '../screens/whereabouts/whereabouts_screen.dart';
import '../widgets/app_shell.dart'; import '../widgets/app_shell.dart';
import '../screens/teams/teams_screen.dart'; import '../screens/teams/teams_screen.dart';
import '../screens/it_service_requests/it_service_requests_list_screen.dart';
import '../screens/it_service_requests/it_service_request_detail_screen.dart';
import '../theme/m3_motion.dart';
import '../utils/navigation.dart';
final appRouterProvider = Provider<GoRouter>((ref) { final appRouterProvider = Provider<GoRouter>((ref) {
final notifier = RouterNotifier(ref); final notifier = RouterNotifier(ref);
ref.onDispose(notifier.dispose); ref.onDispose(notifier.dispose);
return GoRouter( return GoRouter(
navigatorKey: globalNavigatorKey,
initialLocation: '/dashboard', initialLocation: '/dashboard',
refreshListenable: notifier, refreshListenable: notifier,
redirect: (context, state) { redirect: (context, state) {
final authState = ref.read(authStateChangesProvider); final authState = ref.read(authStateChangesProvider);
final session = authState.when( dynamic session;
data: (state) => state.session, if (authState is AsyncData) {
loading: () => ref.read(sessionProvider), final state = authState.value;
error: (error, _) => ref.read(sessionProvider), session = state?.session;
); } else {
session = ref.read(sessionProvider);
}
final isAuthRoute = final isAuthRoute =
state.fullPath == '/login' || state.fullPath == '/signup'; state.fullPath == '/login' || state.fullPath == '/signup';
final isSignedIn = session != null; final isSignedIn = session != null;
final profileAsync = ref.read(currentProfileProvider); final profileAsync = ref.read(currentProfileProvider);
final isAdminRoute = state.matchedLocation.startsWith('/settings'); final isAdminRoute = state.matchedLocation.startsWith('/settings');
final isAdmin = profileAsync.maybeWhen( final role = profileAsync is AsyncData
data: (profile) => profile?.role == 'admin', ? (profileAsync.value)?.role
orElse: () => false, : null;
); final isAdmin = role == 'admin' || role == 'programmer';
final isReportsRoute = state.matchedLocation == '/reports';
final hasReportsAccess =
role == 'admin' ||
role == 'programmer' ||
role == 'dispatcher' ||
role == 'it_staff';
if (!isSignedIn && !isAuthRoute) { if (!isSignedIn && !isAuthRoute) {
return '/login'; return '/login';
@@ -56,6 +76,17 @@ final appRouterProvider = Provider<GoRouter>((ref) {
if (isAdminRoute && !isAdmin) { if (isAdminRoute && !isAdmin) {
return '/tickets'; return '/tickets';
} }
if (isReportsRoute && !hasReportsAccess) {
return '/tickets';
}
// Attendance & Whereabouts: not accessible to standard users
final isStandardOnly = role == 'standard';
final isAttendanceRoute =
state.matchedLocation == '/attendance' ||
state.matchedLocation == '/whereabouts';
if (isAttendanceRoute && isStandardOnly) {
return '/dashboard';
}
return null; return null;
}, },
routes: [ routes: [
@@ -64,87 +95,170 @@ final appRouterProvider = Provider<GoRouter>((ref) {
path: '/signup', path: '/signup',
builder: (context, state) => const SignUpScreen(), builder: (context, state) => const SignUpScreen(),
), ),
GoRoute(
path: '/verify/:sessionId',
builder: (context, state) => MobileVerificationScreen(
sessionId: state.pathParameters['sessionId'] ?? '',
),
),
ShellRoute( ShellRoute(
builder: (context, state, child) => AppScaffold(child: child), builder: (context, state, child) => AppScaffold(child: child),
routes: [ routes: [
GoRoute( GoRoute(
path: '/settings/teams', path: '/settings/teams',
builder: (context, state) => const TeamsScreen(), pageBuilder: (context, state) => M3SharedAxisPage(
key: state.pageKey,
child: const TeamsScreen(),
),
),
GoRoute(
path: '/settings/app-update',
pageBuilder: (context, state) => M3SharedAxisPage(
key: state.pageKey,
child: const AppUpdateScreen(),
),
), ),
GoRoute( GoRoute(
path: '/dashboard', path: '/dashboard',
builder: (context, state) => const DashboardScreen(), pageBuilder: (context, state) => M3SharedAxisPage(
key: state.pageKey,
child: const DashboardScreen(),
),
), ),
GoRoute( GoRoute(
path: '/tickets', path: '/tickets',
builder: (context, state) => const TicketsListScreen(), pageBuilder: (context, state) => M3SharedAxisPage(
key: state.pageKey,
child: const TicketsListScreen(),
),
routes: [ routes: [
GoRoute( GoRoute(
path: ':id', path: ':id',
builder: (context, state) => TicketDetailScreen( pageBuilder: (context, state) => M3ContainerTransformPage(
key: state.pageKey,
child: TicketDetailScreen(
ticketId: state.pathParameters['id'] ?? '', ticketId: state.pathParameters['id'] ?? '',
), ),
), ),
),
], ],
), ),
GoRoute( GoRoute(
path: '/tasks', path: '/tasks',
builder: (context, state) => const TasksListScreen(), pageBuilder: (context, state) => M3SharedAxisPage(
key: state.pageKey,
child: const TasksListScreen(),
),
routes: [ routes: [
GoRoute( GoRoute(
path: ':id', path: ':id',
builder: (context, state) => pageBuilder: (context, state) => M3ContainerTransformPage(
TaskDetailScreen(taskId: state.pathParameters['id'] ?? ''), key: state.pageKey,
child: TaskDetailScreen(
taskId: state.pathParameters['id'] ?? '',
),
),
), ),
], ],
), ),
GoRoute( GoRoute(
path: '/events', path: '/it-service-requests',
builder: (context, state) => const UnderDevelopmentScreen( pageBuilder: (context, state) => M3SharedAxisPage(
title: 'Events', key: state.pageKey,
subtitle: 'Event monitoring is under development.', child: const ItServiceRequestsListScreen(),
icon: Icons.event,
), ),
routes: [
GoRoute(
path: ':id',
pageBuilder: (context, state) => M3ContainerTransformPage(
key: state.pageKey,
child: ItServiceRequestDetailScreen(
requestId: state.pathParameters['id'] ?? '',
),
),
),
],
), ),
GoRoute( GoRoute(
path: '/announcements', path: '/announcements',
builder: (context, state) => const UnderDevelopmentScreen( pageBuilder: (context, state) => M3SharedAxisPage(
key: state.pageKey,
child: const UnderDevelopmentScreen(
title: 'Announcement', title: 'Announcement',
subtitle: 'Operational broadcasts are coming soon.', subtitle: 'Operational broadcasts are coming soon.',
icon: Icons.campaign, icon: Icons.campaign,
), ),
), ),
),
GoRoute( GoRoute(
path: '/workforce', path: '/workforce',
builder: (context, state) => const WorkforceScreen(), pageBuilder: (context, state) => M3SharedAxisPage(
key: state.pageKey,
child: const WorkforceScreen(),
),
),
GoRoute(
path: '/attendance',
pageBuilder: (context, state) => M3SharedAxisPage(
key: state.pageKey,
child: const AttendanceScreen(),
),
),
GoRoute(
path: '/whereabouts',
pageBuilder: (context, state) => M3SharedAxisPage(
key: state.pageKey,
child: const WhereaboutsScreen(),
),
), ),
GoRoute( GoRoute(
path: '/reports', path: '/reports',
builder: (context, state) => const UnderDevelopmentScreen( pageBuilder: (context, state) => M3SharedAxisPage(
title: 'Reports', key: state.pageKey,
subtitle: 'Reporting automation is under development.', child: const ReportsScreen(),
icon: Icons.analytics,
), ),
), ),
GoRoute( GoRoute(
path: '/settings/users', path: '/settings/users',
builder: (context, state) => const UserManagementScreen(), pageBuilder: (context, state) => M3SharedAxisPage(
key: state.pageKey,
child: const UserManagementScreen(),
),
), ),
GoRoute( GoRoute(
path: '/settings/offices', path: '/settings/offices',
builder: (context, state) => const OfficesScreen(), pageBuilder: (context, state) => M3SharedAxisPage(
key: state.pageKey,
child: const OfficesScreen(),
),
), ),
GoRoute( GoRoute(
path: '/settings/geofence-test', path: '/settings/geofence-test',
builder: (context, state) => const GeofenceTestScreen(), pageBuilder: (context, state) => M3SharedAxisPage(
key: state.pageKey,
child: const GeofenceTestScreen(),
),
),
GoRoute(
path: '/settings/permissions',
pageBuilder: (context, state) => M3SharedAxisPage(
key: state.pageKey,
child: const PermissionsScreen(),
),
), ),
GoRoute( GoRoute(
path: '/notifications', path: '/notifications',
builder: (context, state) => const NotificationsScreen(), pageBuilder: (context, state) => M3SharedAxisPage(
key: state.pageKey,
child: const NotificationsScreen(),
),
), ),
GoRoute( GoRoute(
path: '/profile', path: '/profile',
builder: (context, state) => const ProfileScreen(), pageBuilder: (context, state) => M3SharedAxisPage(
key: state.pageKey,
child: const ProfileScreen(),
),
), ),
], ],
), ),
@@ -155,18 +269,19 @@ final appRouterProvider = Provider<GoRouter>((ref) {
class RouterNotifier extends ChangeNotifier { class RouterNotifier extends ChangeNotifier {
RouterNotifier(this.ref) { RouterNotifier(this.ref) {
_authSub = ref.listen(authStateChangesProvider, (previous, next) { _authSub = ref.listen(authStateChangesProvider, (previous, next) {
// Enforce auth-level ban when a session becomes available. // Only enforce lock on successful sign-in events, not on every auth state change
next.when( if (next is AsyncData) {
data: (authState) { final authState = next.value;
final session = authState.session; final session = authState?.session;
if (session != null) { // Only check for bans when we have a session and the previous state didn't
// Fire-and-forget enforcement (best-effort client-side sign-out) final previousSession = previous is AsyncData
enforceLockForCurrentUser(ref.read(supabaseClientProvider)); ? previous.value?.session
: null;
if (session != null && previousSession == null) {
// User just signed in; enforce lock check
_enforceLockAsync();
}
} }
},
loading: () {},
error: (error, _) {},
);
notifyListeners(); notifyListeners();
}); });
_profileSub = ref.listen(currentProfileProvider, (previous, next) { _profileSub = ref.listen(currentProfileProvider, (previous, next) {
@@ -177,6 +292,25 @@ class RouterNotifier extends ChangeNotifier {
final Ref ref; final Ref ref;
late final ProviderSubscription _authSub; late final ProviderSubscription _authSub;
late final ProviderSubscription _profileSub; late final ProviderSubscription _profileSub;
bool _lockEnforcementInProgress = false;
/// Safely enforce lock in the background, preventing concurrent calls
void _enforceLockAsync() {
// Prevent concurrent enforcement calls
if (_lockEnforcementInProgress) return;
_lockEnforcementInProgress = true;
// Use Future.microtask to defer execution and avoid blocking
Future.microtask(() async {
try {
await enforceLockForCurrentUser(ref.read(supabaseClientProvider));
} catch (e) {
debugPrint('RouterNotifier: lock enforcement error: $e');
} finally {
_lockEnforcementInProgress = false;
}
});
}
@override @override
void dispose() { void dispose() {
+915
View File
@@ -0,0 +1,915 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:file_picker/file_picker.dart';
import 'package:pub_semver/pub_semver.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:flutter_quill/flutter_quill.dart' as quill;
import '../../services/ai_service.dart';
import '../../utils/snackbar.dart';
import '../../widgets/app_page_header.dart';
import '../../widgets/gemini_animated_text_field.dart';
/// A simple admin-only web page allowing the upload of a new APK and the
/// associated metadata. After the APK is uploaded to the "apk_updates"
/// bucket the `app_versions` table is updated and any older rows are removed
/// so that only the current entry remains.
class AppUpdateScreen extends ConsumerStatefulWidget {
const AppUpdateScreen({super.key});
@override
ConsumerState<AppUpdateScreen> createState() => _AppUpdateScreenState();
}
class _AppUpdateScreenState extends ConsumerState<AppUpdateScreen> {
final _formKey = GlobalKey<FormState>();
final _versionController = TextEditingController();
final _minController = TextEditingController();
final _notesController = TextEditingController();
quill.QuillController? _quillController;
final FocusNode _quillFocusNode = FocusNode();
final ScrollController _quillScrollController = ScrollController();
bool _isImprovingNotes = false;
String? _existingVersion;
// We store release notes as plain text for compatibility; existing
// Quill delta JSON in the database will be parsed and displayed by
// the Android update dialog renderer.
Uint8List? _apkBytes;
String? _apkName;
bool _isUploading = false;
double? _progress; // null => indeterminate, otherwise 0..1
double _realProgress = 0.0; // actual numeric progress for display (0..1)
String? _eta;
final List<String> _logs = [];
Timer? _progressTimer;
Timer? _startDelayTimer;
String? _error;
@override
void initState() {
super.initState();
// Always have a controller so web edits do not reset when the widget rebuilds.
_setQuillController(quill.QuillController.basic());
_loadCurrent();
}
void _setQuillController(quill.QuillController controller) {
_quillController?.removeListener(_updateQuillToolbar);
_quillController = controller;
_quillController?.addListener(_updateQuillToolbar);
}
void _updateQuillToolbar() {
if (mounted) setState(() {});
}
void _toggleAttribute(quill.Attribute attribute) {
final current = _quillController?.getSelectionStyle();
if (current?.attributes.containsKey(attribute.key) == true) {
_quillController?.formatSelection(quill.Attribute.clone(attribute, null));
} else {
_quillController?.formatSelection(attribute);
}
}
int? _currentHeaderLevel() {
final attrs = _quillController?.getSelectionStyle().attributes;
if (attrs == null) return null;
final Object? headerValue = attrs[quill.Attribute.header.key];
if (headerValue is quill.HeaderAttribute) {
return headerValue.value;
}
if (headerValue is int) {
return headerValue;
}
return null;
}
void _setHeader(int? level) {
if (level == null) {
_quillController?.formatSelection(
quill.Attribute.clone(quill.Attribute.header, null),
);
} else {
_quillController?.formatSelection(quill.HeaderAttribute(level: level));
}
}
Widget _buildToolbarIcon({
required IconData icon,
required String tooltip,
required bool isActive,
required VoidCallback onPressed,
}) {
final theme = Theme.of(context);
return IconButton(
tooltip: tooltip,
icon: Icon(icon),
color: isActive
? theme.colorScheme.primary
: theme.colorScheme.onSurface.withAlpha((0.75 * 255).round()),
onPressed: onPressed,
splashRadius: 20,
);
}
Future<void> _loadCurrent() async {
try {
final client = Supabase.instance.client;
final rows = await client.from('app_versions').select().maybeSingle();
// We will update the UI once we have the values so the initial load
// populates the form fields and editor reliably.
String? existingVersion;
String? versionText;
String? minVersionText;
quill.QuillController? quillController;
// when using text versions we can't rely on server-side ordering; instead
// parse locally and choose the greatest semantic version.
if (rows is List) {
final rowList = rows as List?;
Version? best;
Map<String, dynamic>? bestRow;
if (rowList != null) {
for (final r in rowList) {
if (r is Map<String, dynamic>) {
final v = r['version_code']?.toString() ?? '';
Version parsed;
try {
parsed = Version.parse(v);
} catch (_) {
continue;
}
if (best == null || parsed > best) {
best = parsed;
bestRow = r;
}
}
}
}
if (bestRow != null) {
existingVersion = bestRow['version_code']?.toString();
versionText = bestRow['version_code']?.toString() ?? '';
minVersionText = bestRow['min_version_required']?.toString() ?? '';
final rn = bestRow['release_notes'] ?? '';
if (rn is String && rn.trim().isNotEmpty) {
try {
final parsed = jsonDecode(rn);
if (parsed is List) {
final doc = quill.Document.fromJson(parsed);
quillController = quill.QuillController(
document: doc,
selection: const TextSelection.collapsed(offset: 0),
);
} else {
final doc = quill.Document()..insert(0, rn.toString());
quillController = quill.QuillController(
document: doc,
selection: const TextSelection.collapsed(offset: 0),
);
}
} catch (_) {
final doc = quill.Document()..insert(0, rn.toString());
quillController = quill.QuillController(
document: doc,
selection: const TextSelection.collapsed(offset: 0),
);
}
}
}
} else if (rows is Map<String, dynamic>) {
existingVersion = rows['version_code']?.toString();
versionText = rows['version_code']?.toString() ?? '';
minVersionText = rows['min_version_required']?.toString() ?? '';
final rn = rows['release_notes'] ?? '';
try {
final parsed = jsonDecode(rn);
if (parsed is List) {
final doc = quill.Document.fromJson(parsed);
quillController = quill.QuillController(
document: doc,
selection: const TextSelection.collapsed(offset: 0),
);
} else {
final doc = quill.Document()..insert(0, rn.toString());
quillController = quill.QuillController(
document: doc,
selection: const TextSelection.collapsed(offset: 0),
);
}
} catch (_) {
final doc = quill.Document()..insert(0, rn.toString());
quillController = quill.QuillController(
document: doc,
selection: const TextSelection.collapsed(offset: 0),
);
}
}
if (mounted) {
setState(() {
_existingVersion = existingVersion;
if (versionText != null) {
_versionController.text = versionText;
}
if (minVersionText != null) {
_minController.text = minVersionText;
}
if (quillController != null) {
_setQuillController(quillController);
}
});
}
} catch (_) {}
}
@override
void dispose() {
_versionController.dispose();
_minController.dispose();
_notesController.dispose();
_quillFocusNode.dispose();
_quillScrollController.dispose();
super.dispose();
}
Future<void> _pickApk() async {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['apk'],
);
if (result != null && result.files.single.bytes != null) {
setState(() {
_apkBytes = result.files.single.bytes;
_apkName = result.files.single.name;
});
}
}
Future<void> _improveReleaseNotesWithGemini(BuildContext context) async {
final controller = _quillController;
if (controller == null) return;
final plain = controller.document.toPlainText().trim();
if (plain.isEmpty) {
if (!context.mounted) return;
showWarningSnackBar(context, 'Please enter some release notes first');
return;
}
setState(() {
_isImprovingNotes = true;
});
try {
final aiService = AiService();
final from = _existingVersion?.trim();
final to = _versionController.text.trim();
final prompt = StringBuffer(
'Improve these release notes for a mobile app update. '
'Keep the message concise, clear, and professional. '
'The notes may include formatting (bold, italic, underline, headings, lists, code blocks, links). '
'Do NOT change or remove existing markdown-style formatting markers. '
'Return ONLY the improved release notes in plain text using markdown-style markers (e.g. **bold**, *italic*, `code`, # Heading, - list, [link](url)). '
'Do not output HTML, JSON, or any extra commentary. '
'If the input already contains markdown markers, keep them and do not rewrite them into a different format.',
);
if (from != null && from.isNotEmpty && to.isNotEmpty) {
prompt.write(' Update is from $from$to.');
} else if (to.isNotEmpty) {
prompt.write(' Update version: $to.');
}
final improved = await aiService.enhanceText(
plain,
promptInstruction: prompt.toString(),
);
final trimmed = improved.trim();
final docLen = controller.document.length;
controller.replaceText(
0,
docLen - 1,
trimmed,
TextSelection.collapsed(offset: trimmed.length),
);
if (context.mounted) {
showSuccessSnackBar(context, 'Release notes improved with Gemini');
}
} catch (e) {
if (context.mounted) {
showErrorSnackBar(context, 'Gemini failed: $e');
}
} finally {
if (mounted) {
setState(() {
_isImprovingNotes = false;
});
}
}
}
Future<void> _submit() async {
if (!_formKey.currentState!.validate()) return;
if (_apkBytes == null || _apkName == null) {
setState(() => _error = 'Please select an APK file.');
return;
}
final vcode = _versionController.text.trim();
final minReq = _minController.text.trim();
String notes;
if (_quillController != null) {
notes = jsonEncode(_quillController!.document.toDelta().toJson());
} else {
notes = _notesController.text;
}
setState(() {
_isUploading = true;
_progress = null; // show indeterminate while we attempt to start
_eta = null;
_logs.clear();
_error = null;
});
try {
final client = Supabase.instance.client;
// ensure the user is authenticated (browser uploads require correct auth/CORS)
final user = client.auth.currentUser;
_logs.add('Current user: ${user?.id ?? 'anonymous'}');
if (user == null) {
throw Exception('Not signed in. Please sign in to perform uploads.');
}
// Use a deterministic object name to avoid accidental nesting
final filename = '${DateTime.now().millisecondsSinceEpoch}_$_apkName';
final path = filename;
_logs.add('Starting upload to bucket apk_updates, path: $path');
final stopwatch = Stopwatch()..start();
// Show an indeterminate bar briefly while the upload is negotiating;
// after a short delay, switch to a determinate (fake) progress based on
// the payload size so the UI feels responsive.
final estimatedSeconds = (_apkBytes!.length / 250000).clamp(1, 30);
_startDelayTimer?.cancel();
_startDelayTimer = Timer(const Duration(milliseconds: 700), () {
// keep the bar indeterminate, but start updating the numeric progress
setState(() {
_progress = null;
_realProgress = 0.0;
});
_progressTimer = Timer.periodic(const Duration(milliseconds: 200), (t) {
final elapsed = stopwatch.elapsed.inMilliseconds / 1000.0;
final pct = (elapsed / estimatedSeconds).clamp(0.0, 0.95);
setState(() {
_realProgress = pct;
final remaining = (estimatedSeconds - elapsed).clamp(
0.0,
double.infinity,
);
_eta = '${remaining.toStringAsFixed(1)}s';
});
});
});
// Upload can occasionally stall on web; enforce a timeout so the UI
// shows an error instead of hanging at 95%.
final uploadRes = await client.storage
.from('apk_updates')
.uploadBinary(
path,
_apkBytes!,
fileOptions: const FileOptions(
upsert: true,
contentType: 'application/vnd.android.package-archive',
),
)
.timeout(
const Duration(minutes: 3),
onTimeout: () => throw Exception('Upload timed out. Please retry.'),
);
stopwatch.stop();
_logs.add('Upload finished (took ${stopwatch.elapsed.inSeconds}s)');
_logs.add('Raw upload response: ${uploadRes.runtimeType} - $uploadRes');
if (uploadRes is Map) {
final Map m = uploadRes as Map;
if (m.containsKey('error') && m['error'] != null) {
throw Exception('upload failed: ${m['error']}');
}
}
setState(() {
_realProgress = 0.95;
});
// retrieve public URL; various SDK versions return different structures
dynamic urlRes = client.storage.from('apk_updates').getPublicUrl(path);
_logs.add('Raw getPublicUrl response: ${urlRes.runtimeType} - $urlRes');
String url;
if (urlRes is String) {
url = urlRes;
} else if (urlRes is Map) {
// supabase responses vary by SDK version
if (urlRes['publicUrl'] is String) {
url = urlRes['publicUrl'] as String;
} else if (urlRes['data'] is String) {
url = urlRes['data'] as String;
} else if (urlRes['data'] is Map) {
final d = urlRes['data'] as Map;
url =
(d['publicUrl'] ?? d['public_url'] ?? d['url'] ?? d['publicURL'])
as String? ??
'';
} else {
url = '';
}
} else {
url = '';
}
if (url.isEmpty) {
throw Exception(
'could not obtain public url, check bucket CORS and policies',
);
}
_logs.add('Public URL: $url');
// upsert new version in a single statement
await client.from('app_versions').upsert({
'version_code': vcode,
'min_version_required': minReq,
'download_url': url,
'release_notes': notes,
}, onConflict: 'version_code');
await client.from('app_versions').delete().neq('version_code', vcode);
setState(() {
_realProgress = 1.0;
_progress = 1.0;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Version saved successfully')),
);
}
} catch (e) {
_logs.add('Error during upload: $e');
setState(() {
_error = e.toString();
_progress = null;
_realProgress = 0;
});
} finally {
_startDelayTimer?.cancel();
_startDelayTimer = null;
_progressTimer?.cancel();
_progressTimer = null;
setState(() => _isUploading = false);
}
}
@override
Widget build(BuildContext context) {
if (!kIsWeb) {
return const Center(
child: Text('This page is only available on the web.'),
);
}
return Scaffold(
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const AppPageHeader(
title: 'App Update',
subtitle: 'Upload and manage APK releases',
),
Expanded(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 800),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: Card(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextFormField(
controller: _versionController,
decoration: const InputDecoration(
labelText: 'Version (e.g. 1.2.3)',
),
keyboardType: TextInputType.text,
validator: (v) =>
(v == null || v.isEmpty) ? 'Required' : null,
),
TextFormField(
controller: _minController,
decoration: const InputDecoration(
labelText: 'Min Version (e.g. 0.1.1)',
),
keyboardType: TextInputType.text,
validator: (v) =>
(v == null || v.isEmpty) ? 'Required' : null,
),
// Release notes: use Quill rich editor when available (web).
if (_quillController != null || kIsWeb) ...[
const SizedBox(height: 8),
GeminiAnimatedBorder(
isProcessing: _isImprovingNotes,
borderRadius: 12,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Theme.of(
context,
).colorScheme.outlineVariant,
),
),
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Quill toolbar (basic formatting + headings/lists/links/code)
const SizedBox(height: 8),
Row(
children: [
_buildToolbarIcon(
icon: Icons.format_bold,
tooltip: 'Bold',
isActive:
_quillController
?.getSelectionStyle()
.attributes
.containsKey(
quill.Attribute.bold.key,
) ==
true,
onPressed: () {
_toggleAttribute(quill.Attribute.bold);
},
),
_buildToolbarIcon(
icon: Icons.format_italic,
tooltip: 'Italic',
isActive:
_quillController
?.getSelectionStyle()
.attributes
.containsKey(
quill.Attribute.italic.key,
) ==
true,
onPressed: () {
_toggleAttribute(
quill.Attribute.italic,
);
},
),
_buildToolbarIcon(
icon: Icons.format_underline,
tooltip: 'Underline',
isActive:
_quillController
?.getSelectionStyle()
.attributes
.containsKey(
quill.Attribute.underline.key,
) ==
true,
onPressed: () {
_toggleAttribute(
quill.Attribute.underline,
);
},
),
_buildToolbarIcon(
icon: Icons.format_list_bulleted,
tooltip: 'Bullet list',
isActive:
_quillController
?.getSelectionStyle()
.attributes
.containsKey(
quill.Attribute.ul.key,
) ==
true,
onPressed: () {
_toggleAttribute(quill.Attribute.ul);
},
),
_buildToolbarIcon(
icon: Icons.code,
tooltip: 'Code block',
isActive:
_quillController
?.getSelectionStyle()
.attributes
.containsKey(
quill.Attribute.codeBlock.key,
) ==
true,
onPressed: () {
_toggleAttribute(
quill.Attribute.codeBlock,
);
},
),
_buildToolbarIcon(
icon: Icons.format_size,
tooltip: 'Heading 1',
isActive: _currentHeaderLevel() == 1,
onPressed: () {
_setHeader(1);
},
),
_buildToolbarIcon(
icon: Icons.format_size,
tooltip: 'Heading 2',
isActive: _currentHeaderLevel() == 2,
onPressed: () {
_setHeader(2);
},
),
_buildToolbarIcon(
icon: Icons.format_size,
tooltip: 'Heading 3',
isActive: _currentHeaderLevel() == 3,
onPressed: () {
_setHeader(3);
},
),
_buildToolbarIcon(
icon: Icons.link,
tooltip: 'Link',
isActive:
_quillController
?.getSelectionStyle()
.attributes
.containsKey(
quill.Attribute.link.key,
) ==
true,
onPressed: () async {
final selection =
_quillController!.selection;
if (selection.isCollapsed) return;
final current = _quillController!
.getSelectionStyle();
final existing =
current.attributes[quill
.Attribute
.link
.key];
final url = await showDialog<String>(
context: context,
builder: (context) {
final ctrl = TextEditingController(
text: existing?.value as String?,
);
return AlertDialog(
title: const Text('Insert link'),
content: TextField(
controller: ctrl,
decoration:
const InputDecoration(
labelText: 'URL',
),
keyboardType: TextInputType.url,
),
actions: [
TextButton(
onPressed: () => Navigator.of(
context,
).pop(),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
Navigator.of(
context,
).pop(ctrl.text.trim());
},
child: const Text('OK'),
),
],
);
},
);
if (url == null) return;
if (url.isEmpty) {
_toggleAttribute(
quill.Attribute.link,
);
} else {
_quillController!.formatSelection(
quill.LinkAttribute(url),
);
}
},
),
],
),
const SizedBox(height: 8),
SizedBox(
height: 200,
child: quill.QuillEditor.basic(
controller: _quillController!,
focusNode: _quillFocusNode,
scrollController: _quillScrollController,
config: quill.QuillEditorConfig(
scrollable: true,
padding: EdgeInsets.zero,
),
),
),
const SizedBox(height: 8),
Row(
children: [
FilledButton.icon(
onPressed: _isImprovingNotes
? null
: () =>
_improveReleaseNotesWithGemini(
context,
),
icon: _isImprovingNotes
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(Icons.auto_awesome),
label: const Text('Improve'),
),
if (_isImprovingNotes) ...[
const SizedBox(width: 12),
Text(
'Improving...',
style: Theme.of(
context,
).textTheme.bodySmall,
),
],
],
),
],
),
),
),
] else ...[
TextFormField(
controller: _notesController,
decoration: const InputDecoration(
labelText: 'Release Notes',
),
maxLines: 3,
),
],
const SizedBox(height: 12),
Row(
children: [
ElevatedButton(
onPressed: _isUploading ? null : _pickApk,
child: const Text('Select APK'),
),
const SizedBox(width: 8),
Expanded(child: Text(_apkName ?? 'no file chosen')),
],
),
const SizedBox(height: 20),
if (_isUploading) ...[
// keep the animated indeterminate bar while showing the
// numeric progress percentage on top (smoothly animated).
Stack(
alignment: Alignment.center,
children: [
SizedBox(
width: double.infinity,
child: LinearProgressIndicator(value: _progress),
),
// Smoothly animate the displayed percentage so updates feel fluid
TweenAnimationBuilder<double>(
tween: Tween(begin: 0.0, end: _realProgress),
duration: const Duration(milliseconds: 300),
builder: (context, value, child) {
final pct = (value * 100).clamp(0.0, 100.0);
return Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.surface.withAlpha(153),
borderRadius: BorderRadius.circular(12),
),
child: Text(
'${pct.toStringAsFixed(pct >= 10 ? 0 : 1)}% ',
style: Theme.of(
context,
).textTheme.bodyMedium,
),
);
},
),
],
),
if (_eta != null)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text('ETA: $_eta'),
),
const SizedBox(height: 12),
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 150),
child: ListView(
shrinkWrap: true,
children: _logs.map((l) => Text(l)).toList(),
),
),
const SizedBox(height: 12),
],
if (_error != null) ...[
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.error_outline, color: Colors.red),
const SizedBox(width: 8),
Expanded(
child: Text(
_error!,
style: const TextStyle(color: Colors.red),
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
OutlinedButton(
onPressed: _isUploading
? null
: () {
setState(() {
_error = null;
});
_submit();
},
child: const Text('Retry'),
),
const SizedBox(width: 8),
TextButton(
onPressed: _isUploading
? null
: () {
setState(() {
_error = null;
_apkBytes = null;
_apkName = null;
_progress = null;
_realProgress = 0;
_eta = null;
_logs.clear();
});
},
child: const Text('Reset'),
),
],
),
const SizedBox(height: 12),
],
ElevatedButton(
onPressed: _isUploading ? null : _submit,
child: _isUploading
? const CircularProgressIndicator()
: const Text('Save'),
),
],
),
),
),
),
),
),
),
),
],
),
);
}
}

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