Compare commits
104 Commits
alpha
...
74197c525d
| Author | SHA1 | Date | |
|---|---|---|---|
| 74197c525d | |||
| 27ebb89052 | |||
| a39e33bc6b | |||
| 4b63b55812 | |||
| 3af7a1e348 | |||
| 4eaf9444f0 | |||
| 84837c4bf2 | |||
| 3e3e4d560e | |||
| eeab3b1fcf | |||
| 81853c4367 | |||
| 9f7791e56f | |||
| 885b543fb5 | |||
| 6fd3b66251 | |||
| 9bbaf67fef | |||
| 9267ebee2c | |||
| 0bd2a94ece | |||
| f8c79acbbc | |||
| 21e6d68910 | |||
| 24ecca9f06 | |||
| ccc1c62262 | |||
| 1e1c7d9552 | |||
| b8ec5dbf69 | |||
| aee4604fed | |||
| ac38a21f9a | |||
| 0f675d4274 | |||
| f8502f01b6 | |||
| 9178b438a2 | |||
| 8bf0dc13d7 | |||
| ce82a88e04 | |||
| d87b5e73d7 | |||
| a8751ca728 | |||
| d3da8901a4 | |||
| 88432551c8 | |||
| e4391ac465 | |||
| c644143198 | |||
| 4f2fe38c15 | |||
| 3dbebd4006 | |||
| 52ef36faac | |||
| c6f536edeb | |||
| 73dc735cce | |||
| 82fe619f22 | |||
| 94088a8796 | |||
| 81bc618eee | |||
| c123c09233 | |||
| b5449f7842 | |||
| 2e99ec1234 | |||
| 01c430c812 | |||
| b1f5d209a2 | |||
| bfcca47353 | |||
| 1e678ea2e5 | |||
| d9270b3edf | |||
| 7115e2df05 | |||
| 5713581992 | |||
| eb49329b16 | |||
| 43d2bd4f95 | |||
| b9722106ff | |||
| 3950f3ee94 | |||
| 029e671367 | |||
| b9153a070f | |||
| 830c99a3ff | |||
| 2100516238 | |||
| 294d3f7470 | |||
| ed078f24ec | |||
| c9479f01f0 | |||
| e91e7b43d2 | |||
| ec46c33c35 | |||
| d859524a9e | |||
| 70cdbec5d4 | |||
| f7f22c50d2 | |||
| 1ce38618f6 | |||
| d3239d8c76 | |||
| c5e859ad88 | |||
| 9bad41a5ee | |||
| e75d61ac64 | |||
| 6882fdcac8 | |||
| 97d9e83f17 | |||
| e99b87bd20 | |||
| 5a74299a1c | |||
| 0a8e388757 | |||
| dab43a7f30 | |||
| 9cc99e612a | |||
| 6ccf820438 | |||
| 1807dca57d | |||
| db14ec3916 | |||
| 3a923ea7f6 | |||
| aef88de54b | |||
| 0173e91d94 | |||
| 22d8b4d778 | |||
| eaabc0114c | |||
| 8e8269d12b | |||
| cb71a7ea3a | |||
| 7c3a5a1cef | |||
| 546c254326 | |||
| 892fbee456 | |||
| 354b27aad1 | |||
| 5979a04254 | |||
| cc6fda0e79 | |||
| 98355c3707 | |||
| 1074572905 | |||
| 0b900d3480 | |||
| ee1bf5e113 | |||
| 9a0cf7a89d | |||
| 3f9f576b33 | |||
| 751cafcf76 |
@@ -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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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.
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
@@ -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.
|
||||||
@@ -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.1(bounce 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"
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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" />
|
||||||
@@ -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>
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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 |
@@ -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"}}}}}}
|
||||||
@@ -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,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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 re‑enabled 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();
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 publicly‑accessible 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
|||||||
@@ -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
@@ -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),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
@@ -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
|
|
||||||
.from('notifications')
|
final wrapper = StreamRecoveryWrapper<NotificationItem>(
|
||||||
.stream(primaryKey: ['id'])
|
stream: client
|
||||||
.eq('user_id', userId)
|
.from('notifications')
|
||||||
.order('created_at', ascending: false)
|
.stream(primaryKey: ['id'])
|
||||||
.map((rows) => rows.map(NotificationItem.fromMap).toList());
|
.eq('user_id', userId)
|
||||||
|
.order('created_at', ascending: false),
|
||||||
|
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
|
'message_id': messageId,
|
||||||
.map(
|
...?(ticketId != null ? {'ticket_id': ticketId} : null),
|
||||||
(userId) => {
|
...?(taskId != null ? {'task_id': taskId} : null),
|
||||||
'user_id': userId,
|
},
|
||||||
'actor_id': actorId,
|
pushTitle: 'New mention',
|
||||||
'ticket_id': ticketId,
|
pushBody: 'You were mentioned in a message',
|
||||||
'task_id': taskId,
|
pushData: {
|
||||||
'message_id': messageId,
|
...?(ticketId != null ? {'ticket_id': ticketId} : null),
|
||||||
'type': 'mention',
|
...?(taskId != null ? {'task_id': taskId} : null),
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
.toList();
|
|
||||||
await _client.from('notifications').insert(rows);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
.from('profiles')
|
final wrapper = StreamRecoveryWrapper<Profile?>(
|
||||||
.stream(primaryKey: ['id'])
|
stream: client.from('profiles').stream(primaryKey: ['id']).eq('id', userId),
|
||||||
.eq('id', userId)
|
onPollData: () async {
|
||||||
.map((rows) => rows.isEmpty ? null : Profile.fromMap(rows.first));
|
final data = await client
|
||||||
|
.from('profiles')
|
||||||
|
.select()
|
||||||
|
.eq('id', userId)
|
||||||
|
.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
|
|
||||||
.from('profiles')
|
final wrapper = StreamRecoveryWrapper<Profile>(
|
||||||
.stream(primaryKey: ['id'])
|
stream: client
|
||||||
.order('full_name')
|
.from('profiles')
|
||||||
.map((rows) => rows.map(Profile.fromMap).toList());
|
.stream(primaryKey: ['id'])
|
||||||
|
.order('full_name'),
|
||||||
|
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,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
+1109
-136
File diff suppressed because it is too large
Load Diff
@@ -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
|
|
||||||
.from('team_members')
|
final wrapper = StreamRecoveryWrapper<TeamMember>(
|
||||||
.stream(primaryKey: ['team_id', 'user_id'])
|
stream: client
|
||||||
.map((rows) => rows.map((r) => TeamMember.fromMap(r)).toList());
|
.from('team_members')
|
||||||
|
.stream(primaryKey: ['team_id', 'user_id']),
|
||||||
|
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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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.
|
assignmentsAsync.valueOrNull
|
||||||
final baseStream = client
|
?.where((a) => a.userId == profile.id)
|
||||||
.from('tickets')
|
.map((a) => a.officeId)
|
||||||
.stream(primaryKey: ['id'])
|
.toList() ??
|
||||||
.map((rows) => rows.map(Ticket.fromMap).toList());
|
<String>[];
|
||||||
|
|
||||||
return baseStream.map((allTickets) {
|
// Wrap realtime stream with recovery logic
|
||||||
debugPrint('[ticketsProvider] stream event: ${allTickets.length} rows');
|
final wrapper = StreamRecoveryWrapper<Ticket>(
|
||||||
var list = allTickets;
|
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,
|
||||||
|
);
|
||||||
|
|
||||||
if (!isGlobal) {
|
ref.onDispose(wrapper.dispose);
|
||||||
final officeIds =
|
|
||||||
assignmentsAsync.valueOrNull
|
|
||||||
?.where((assignment) => assignment.userId == profile.id)
|
|
||||||
.map((assignment) => assignment.officeId)
|
|
||||||
.toSet()
|
|
||||||
.toList() ??
|
|
||||||
<String>[];
|
|
||||||
if (officeIds.isEmpty) return <Ticket>[];
|
|
||||||
final allowedOffices = officeIds.toSet();
|
|
||||||
list = list.where((t) => allowedOffices.contains(t.officeId)).toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (query.officeId != null) {
|
var lastResultHash = '';
|
||||||
list = list.where((t) => t.officeId == query.officeId).toList();
|
Timer? debounceTimer;
|
||||||
}
|
// broadcast() so Riverpod and any other listener can both receive events.
|
||||||
if (query.status != null) {
|
final controller = StreamController<List<Ticket>>.broadcast();
|
||||||
list = list.where((t) => t.status == query.status).toList();
|
|
||||||
}
|
|
||||||
if (query.searchQuery.isNotEmpty) {
|
|
||||||
final q = query.searchQuery.toLowerCase();
|
|
||||||
list = list
|
|
||||||
.where(
|
|
||||||
(t) =>
|
|
||||||
t.subject.toLowerCase().contains(q) ||
|
|
||||||
t.description.toLowerCase().contains(q),
|
|
||||||
)
|
|
||||||
.toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort: newest first
|
void emitDebounced(List<Ticket> tickets) {
|
||||||
list.sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
debounceTimer?.cancel();
|
||||||
|
debounceTimer = Timer(const Duration(milliseconds: 150), () {
|
||||||
|
if (!controller.isClosed) controller.add(tickets);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Pagination
|
ref.onDispose(() {
|
||||||
final start = query.offset;
|
debounceTimer?.cancel();
|
||||||
final end = (start + query.limit).clamp(0, list.length);
|
controller.close();
|
||||||
if (start >= list.length) return <Ticket>[];
|
|
||||||
return list.sublist(start, end);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── 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
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
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>>[];
|
||||||
|
}
|
||||||
|
list = list
|
||||||
|
.where((t) => allowedOfficeIds.contains(t['office_id']))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
final officeId = payload['officeId'] as String?;
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
final searchQuery = (payload['searchQuery'] as String?) ?? '';
|
||||||
|
if (searchQuery.isNotEmpty) {
|
||||||
|
final q = searchQuery.toLowerCase();
|
||||||
|
list = list.where((t) {
|
||||||
|
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
|
|
||||||
.from('ticket_messages')
|
final wrapper = StreamRecoveryWrapper<TicketMessage>(
|
||||||
.stream(primaryKey: ['id'])
|
stream: client
|
||||||
.eq('ticket_id', ticketId)
|
.from('ticket_messages')
|
||||||
.order('created_at', ascending: false)
|
.stream(primaryKey: ['id'])
|
||||||
.map((rows) => rows.map(TicketMessage.fromMap).toList());
|
.eq('ticket_id', ticketId)
|
||||||
|
.order('created_at', ascending: false),
|
||||||
|
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
|
|
||||||
.from('ticket_messages')
|
final wrapper = StreamRecoveryWrapper<TicketMessage>(
|
||||||
.stream(primaryKey: ['id'])
|
stream: client
|
||||||
.order('created_at', ascending: false)
|
.from('ticket_messages')
|
||||||
.map((rows) => rows.map(TicketMessage.fromMap).toList());
|
.stream(primaryKey: ['id'])
|
||||||
|
.order('created_at', ascending: false),
|
||||||
|
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
|
|
||||||
.from('ticket_messages')
|
final wrapper = StreamRecoveryWrapper<TicketMessage>(
|
||||||
.stream(primaryKey: ['id'])
|
stream: client
|
||||||
.eq('task_id', taskId)
|
.from('ticket_messages')
|
||||||
.order('created_at', ascending: false)
|
.stream(primaryKey: ['id'])
|
||||||
.map((rows) => rows.map(TicketMessage.fromMap).toList());
|
.eq('task_id', taskId)
|
||||||
|
.order('created_at', ascending: false),
|
||||||
|
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 {
|
||||||
|
|||||||
+118
-105
@@ -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,145 +66,158 @@ 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) {
|
state = state.copyWith(lastPayload: data);
|
||||||
debugPrint(
|
if (userId == null || userId == currentUserId) {
|
||||||
'TypingIndicatorController: payload received but controller disposed/unmounted',
|
return;
|
||||||
);
|
|
||||||
debugPrint(StackTrace.current.toString());
|
|
||||||
}
|
}
|
||||||
return;
|
if (rawType == 'stop') {
|
||||||
|
_clearRemoteTyping(userId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_markRemoteTyping(userId);
|
||||||
|
} catch (e, st) {
|
||||||
|
debugPrint(
|
||||||
|
'TypingIndicatorController: broadcast callback error: $e\n$st',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
state = state.copyWith(lastPayload: data);
|
|
||||||
if (userId == null || userId == currentUserId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (rawType == 'stop') {
|
|
||||||
_clearRemoteTyping(userId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_markRemoteTyping(userId);
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
channel.subscribe((status, error) {
|
channel.subscribe((status, error) {
|
||||||
if (_disposed || !mounted) {
|
try {
|
||||||
if (kDebugMode) {
|
if (_disposed || !mounted) return;
|
||||||
debugPrint(
|
state = state.copyWith(channelStatus: status.name);
|
||||||
'TypingIndicatorController: subscribe callback skipped (disposed|unmounted)',
|
if (error != null) {
|
||||||
);
|
debugPrint('TypingIndicatorController: subscribe error: $error');
|
||||||
debugPrint(StackTrace.current.toString());
|
|
||||||
}
|
}
|
||||||
return;
|
} catch (e, st) {
|
||||||
|
debugPrint(
|
||||||
|
'TypingIndicatorController: subscribe callback error: $e\n$st',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
state = state.copyWith(channelStatus: status.name);
|
|
||||||
});
|
});
|
||||||
_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);
|
||||||
|
}
|
||||||
|
if (current.containsKey('payload') &&
|
||||||
|
current['payload'] is Map<String, dynamic>) {
|
||||||
|
current = current['payload'];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Some realtime envelope stores the payload at `data`.
|
||||||
|
if (current.containsKey('data') &&
|
||||||
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return payload;
|
} catch (_) {}
|
||||||
}
|
// As a last-resort, do a shallow recursive search for a map containing
|
||||||
final dynamic inner = payload.payload;
|
// `user_id` in case the realtime client used a Map<dynamic,dynamic>
|
||||||
if (inner is Map<String, dynamic>) {
|
// shape that wasn't caught above.
|
||||||
return inner;
|
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);
|
||||||
|
|||||||
@@ -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
|
|
||||||
.from('user_offices')
|
final wrapper = StreamRecoveryWrapper<UserOffice>(
|
||||||
.stream(primaryKey: ['user_id', 'office_id'])
|
stream: client
|
||||||
.order('created_at')
|
.from('user_offices')
|
||||||
.map((rows) => rows.map(UserOffice.fromMap).toList());
|
.stream(primaryKey: ['user_id', 'office_id'])
|
||||||
|
.order('created_at'),
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
.order('created_at', ascending: false)
|
|
||||||
.map((rows) => rows.map(SwapRequest.fromMap).toList());
|
final wrapper = StreamRecoveryWrapper<SwapRequest>(
|
||||||
}
|
stream: isAdmin
|
||||||
return base
|
? client
|
||||||
.order('created_at', ascending: false)
|
.from('swap_requests')
|
||||||
.map(
|
.stream(primaryKey: ['id'])
|
||||||
(rows) => rows
|
.order('created_at', ascending: false)
|
||||||
.where(
|
: client
|
||||||
(row) =>
|
.from('swap_requests')
|
||||||
row['requester_id'] == profile.id ||
|
.stream(primaryKey: ['id'])
|
||||||
row['recipient_id'] == profile.id,
|
.order('created_at', ascending: false),
|
||||||
)
|
onPollData: () async {
|
||||||
.map(SwapRequest.fromMap)
|
final data = await client
|
||||||
.toList(),
|
.from('swap_requests')
|
||||||
);
|
.select()
|
||||||
|
.order('created_at', ascending: false);
|
||||||
|
return data.map(SwapRequest.fromMap).toList();
|
||||||
|
},
|
||||||
|
fromMap: SwapRequest.fromMap,
|
||||||
|
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');
|
||||||
|
|||||||
+182
-48
@@ -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(
|
||||||
ticketId: state.pathParameters['id'] ?? '',
|
key: state.pageKey,
|
||||||
|
child: TicketDetailScreen(
|
||||||
|
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(
|
||||||
title: 'Announcement',
|
key: state.pageKey,
|
||||||
subtitle: 'Operational broadcasts are coming soon.',
|
child: const UnderDevelopmentScreen(
|
||||||
icon: Icons.campaign,
|
title: 'Announcement',
|
||||||
|
subtitle: 'Operational broadcasts are coming soon.',
|
||||||
|
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) {
|
||||||
loading: () {},
|
// User just signed in; enforce lock check
|
||||||
error: (error, _) {},
|
_enforceLockAsync();
|
||||||
);
|
}
|
||||||
|
}
|
||||||
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() {
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user