feat: Add overlay_json field for Quip text/sticker decorations
- Go: Add overlay_json to Post model, CreatePost handler, and all post queries - Flutter: Rename QuipTextOverlay → QuipOverlayItem with type enum (text/sticker) - QuipOverlayItem: add id field, content replaces text, backward-compat alias - quip_video_item: parse overlay_json and render non-interactive overlays in feed - quip_upload_provider: accept overlayJson param and pass to publishPost - api_service: add overlayJson param to publish
This commit is contained in:
parent
d26e63ca1b
commit
5b5e89e383
|
|
@ -515,6 +515,7 @@ func (h *PostHandler) CreatePost(c *gin.Context) {
|
||||||
IsNSFW bool `json:"is_nsfw"`
|
IsNSFW bool `json:"is_nsfw"`
|
||||||
NSFWReason string `json:"nsfw_reason"`
|
NSFWReason string `json:"nsfw_reason"`
|
||||||
Visibility string `json:"visibility"`
|
Visibility string `json:"visibility"`
|
||||||
|
OverlayJSON *string `json:"overlay_json"`
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
|
@ -611,11 +612,12 @@ func (h *PostHandler) CreatePost(c *gin.Context) {
|
||||||
}
|
}
|
||||||
return "public"
|
return "public"
|
||||||
}(),
|
}(),
|
||||||
ExpiresAt: expiresAt,
|
ExpiresAt: expiresAt,
|
||||||
IsNSFW: req.IsNSFW,
|
IsNSFW: req.IsNSFW,
|
||||||
NSFWReason: req.NSFWReason,
|
NSFWReason: req.NSFWReason,
|
||||||
Lat: req.BeaconLat,
|
Lat: req.BeaconLat,
|
||||||
Long: req.BeaconLong,
|
Long: req.BeaconLong,
|
||||||
|
OverlayJSON: req.OverlayJSON,
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.CategoryID != nil {
|
if req.CategoryID != nil {
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,9 @@ type Post struct {
|
||||||
NSFWReason string `json:"nsfw_reason" db:"nsfw_reason"`
|
NSFWReason string `json:"nsfw_reason" db:"nsfw_reason"`
|
||||||
ExpiresAt *time.Time `json:"expires_at" db:"expires_at"`
|
ExpiresAt *time.Time `json:"expires_at" db:"expires_at"`
|
||||||
|
|
||||||
|
// Quip overlay JSON — stores text/sticker decorations as client-rendered widgets
|
||||||
|
OverlayJSON *string `json:"overlay_json,omitempty" db:"overlay_json"`
|
||||||
|
|
||||||
// Link preview (populated via enrichment, not in every query)
|
// Link preview (populated via enrichment, not in every query)
|
||||||
LinkPreviewURL *string `json:"link_preview_url,omitempty" db:"link_preview_url"`
|
LinkPreviewURL *string `json:"link_preview_url,omitempty" db:"link_preview_url"`
|
||||||
LinkPreviewTitle *string `json:"link_preview_title,omitempty" db:"link_preview_title"`
|
LinkPreviewTitle *string `json:"link_preview_title,omitempty" db:"link_preview_title"`
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ func (r *PostRepository) CreatePost(ctx context.Context, post *models.Post) erro
|
||||||
is_beacon, beacon_type, location, confidence_score,
|
is_beacon, beacon_type, location, confidence_score,
|
||||||
is_active_beacon, allow_chain, chain_parent_id, visibility, expires_at,
|
is_active_beacon, allow_chain, chain_parent_id, visibility, expires_at,
|
||||||
is_nsfw, nsfw_reason,
|
is_nsfw, nsfw_reason,
|
||||||
severity, incident_status, radius
|
severity, incident_status, radius, overlay_json
|
||||||
) VALUES (
|
) VALUES (
|
||||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13,
|
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13,
|
||||||
$14, $15,
|
$14, $15,
|
||||||
|
|
@ -68,7 +68,7 @@ func (r *PostRepository) CreatePost(ctx context.Context, post *models.Post) erro
|
||||||
ELSE NULL END,
|
ELSE NULL END,
|
||||||
$18, $19, $20, $21, $22, $23,
|
$18, $19, $20, $21, $22, $23,
|
||||||
$24, $25,
|
$24, $25,
|
||||||
$26, $27, $28
|
$26, $27, $28, $29
|
||||||
) RETURNING id, created_at
|
) RETURNING id, created_at
|
||||||
`
|
`
|
||||||
|
|
||||||
|
|
@ -84,7 +84,7 @@ func (r *PostRepository) CreatePost(ctx context.Context, post *models.Post) erro
|
||||||
post.IsBeacon, post.BeaconType, post.Lat, post.Long, post.Confidence,
|
post.IsBeacon, post.BeaconType, post.Lat, post.Long, post.Confidence,
|
||||||
post.IsActiveBeacon, post.AllowChain, post.ChainParentID, post.Visibility, post.ExpiresAt,
|
post.IsActiveBeacon, post.AllowChain, post.ChainParentID, post.Visibility, post.ExpiresAt,
|
||||||
post.IsNSFW, post.NSFWReason,
|
post.IsNSFW, post.NSFWReason,
|
||||||
post.Severity, post.IncidentStatus, post.Radius,
|
post.Severity, post.IncidentStatus, post.Radius, post.OverlayJSON,
|
||||||
).Scan(&post.ID, &post.CreatedAt)
|
).Scan(&post.ID, &post.CreatedAt)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -168,7 +168,8 @@ func (r *PostRepository) GetFeed(ctx context.Context, userID string, categorySlu
|
||||||
CASE WHEN ($4::text) != '' THEN COALESCE((SELECT jsonb_agg(emoji) FROM public.post_reactions WHERE post_id = p.id AND user_id = $4::text::uuid), '[]'::jsonb) ELSE '[]'::jsonb END as my_reactions,
|
CASE WHEN ($4::text) != '' THEN COALESCE((SELECT jsonb_agg(emoji) FROM public.post_reactions WHERE post_id = p.id AND user_id = $4::text::uuid), '[]'::jsonb) ELSE '[]'::jsonb END as my_reactions,
|
||||||
COALESCE(p.is_nsfw, FALSE) as is_nsfw,
|
COALESCE(p.is_nsfw, FALSE) as is_nsfw,
|
||||||
COALESCE(p.nsfw_reason, '') as nsfw_reason,
|
COALESCE(p.nsfw_reason, '') as nsfw_reason,
|
||||||
p.link_preview_url, p.link_preview_title, p.link_preview_description, p.link_preview_image_url, p.link_preview_site_name
|
p.link_preview_url, p.link_preview_title, p.link_preview_description, p.link_preview_image_url, p.link_preview_site_name,
|
||||||
|
p.overlay_json
|
||||||
FROM public.posts p
|
FROM public.posts p
|
||||||
JOIN public.profiles pr ON p.author_id = pr.id
|
JOIN public.profiles pr ON p.author_id = pr.id
|
||||||
LEFT JOIN public.post_metrics m ON p.id = m.post_id
|
LEFT JOIN public.post_metrics m ON p.id = m.post_id
|
||||||
|
|
@ -220,6 +221,7 @@ func (r *PostRepository) GetFeed(ctx context.Context, userID string, categorySlu
|
||||||
&p.AllowChain, &p.Visibility, &p.Reactions, &p.MyReactions,
|
&p.AllowChain, &p.Visibility, &p.Reactions, &p.MyReactions,
|
||||||
&p.IsNSFW, &p.NSFWReason,
|
&p.IsNSFW, &p.NSFWReason,
|
||||||
&p.LinkPreviewURL, &p.LinkPreviewTitle, &p.LinkPreviewDescription, &p.LinkPreviewImageURL, &p.LinkPreviewSiteName,
|
&p.LinkPreviewURL, &p.LinkPreviewTitle, &p.LinkPreviewDescription, &p.LinkPreviewImageURL, &p.LinkPreviewSiteName,
|
||||||
|
&p.OverlayJSON,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -358,7 +360,8 @@ func (r *PostRepository) GetPostByID(ctx context.Context, postID string, userID
|
||||||
p.allow_chain, p.visibility,
|
p.allow_chain, p.visibility,
|
||||||
COALESCE(p.is_nsfw, FALSE) as is_nsfw,
|
COALESCE(p.is_nsfw, FALSE) as is_nsfw,
|
||||||
COALESCE(p.nsfw_reason, '') as nsfw_reason,
|
COALESCE(p.nsfw_reason, '') as nsfw_reason,
|
||||||
p.link_preview_url, p.link_preview_title, p.link_preview_description, p.link_preview_image_url, p.link_preview_site_name
|
p.link_preview_url, p.link_preview_title, p.link_preview_description, p.link_preview_image_url, p.link_preview_site_name,
|
||||||
|
p.overlay_json
|
||||||
FROM public.posts p
|
FROM public.posts p
|
||||||
JOIN public.profiles pr ON p.author_id = pr.id
|
JOIN public.profiles pr ON p.author_id = pr.id
|
||||||
LEFT JOIN public.post_metrics m ON p.id = m.post_id
|
LEFT JOIN public.post_metrics m ON p.id = m.post_id
|
||||||
|
|
@ -383,6 +386,7 @@ func (r *PostRepository) GetPostByID(ctx context.Context, postID string, userID
|
||||||
&p.AllowChain, &p.Visibility,
|
&p.AllowChain, &p.Visibility,
|
||||||
&p.IsNSFW, &p.NSFWReason,
|
&p.IsNSFW, &p.NSFWReason,
|
||||||
&p.LinkPreviewURL, &p.LinkPreviewTitle, &p.LinkPreviewDescription, &p.LinkPreviewImageURL, &p.LinkPreviewSiteName,
|
&p.LinkPreviewURL, &p.LinkPreviewTitle, &p.LinkPreviewDescription, &p.LinkPreviewImageURL, &p.LinkPreviewSiteName,
|
||||||
|
&p.OverlayJSON,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,43 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
/// Model for text overlays on Quip videos
|
/// Type of overlay item on a Quip video.
|
||||||
class QuipTextOverlay {
|
enum QuipOverlayType { text, sticker }
|
||||||
final String text;
|
|
||||||
final Color color;
|
|
||||||
final Offset position; // Normalized 0.0-1.0 coordinates
|
|
||||||
final double scale;
|
|
||||||
final double rotation; // In radians
|
|
||||||
|
|
||||||
const QuipTextOverlay({
|
/// A single overlay item (text or sticker/emoji) placed on a Quip video.
|
||||||
required this.text,
|
/// Position is normalized (0.0–1.0) relative to the video dimensions so it
|
||||||
required this.color,
|
/// renders correctly at any screen size.
|
||||||
required this.position,
|
class QuipOverlayItem {
|
||||||
|
final String id; // unique identifier for widget keying
|
||||||
|
final QuipOverlayType type;
|
||||||
|
final String content; // text string or emoji/sticker character
|
||||||
|
final Color color; // text color (default white)
|
||||||
|
final Offset position; // normalized 0.0–1.0
|
||||||
|
final double scale;
|
||||||
|
final double rotation; // radians
|
||||||
|
|
||||||
|
const QuipOverlayItem({
|
||||||
|
required this.id,
|
||||||
|
required this.type,
|
||||||
|
required this.content,
|
||||||
|
this.color = Colors.white,
|
||||||
|
this.position = const Offset(0.5, 0.5),
|
||||||
this.scale = 1.0,
|
this.scale = 1.0,
|
||||||
this.rotation = 0.0,
|
this.rotation = 0.0,
|
||||||
});
|
});
|
||||||
|
|
||||||
QuipTextOverlay copyWith({
|
QuipOverlayItem copyWith({
|
||||||
String? text,
|
String? id,
|
||||||
|
QuipOverlayType? type,
|
||||||
|
String? content,
|
||||||
Color? color,
|
Color? color,
|
||||||
Offset? position,
|
Offset? position,
|
||||||
double? scale,
|
double? scale,
|
||||||
double? rotation,
|
double? rotation,
|
||||||
}) {
|
}) {
|
||||||
return QuipTextOverlay(
|
return QuipOverlayItem(
|
||||||
text: text ?? this.text,
|
id: id ?? this.id,
|
||||||
|
type: type ?? this.type,
|
||||||
|
content: content ?? this.content,
|
||||||
color: color ?? this.color,
|
color: color ?? this.color,
|
||||||
position: position ?? this.position,
|
position: position ?? this.position,
|
||||||
scale: scale ?? this.scale,
|
scale: scale ?? this.scale,
|
||||||
|
|
@ -34,7 +47,9 @@ class QuipTextOverlay {
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
return {
|
return {
|
||||||
'text': text,
|
'id': id,
|
||||||
|
'type': type.name,
|
||||||
|
'content': content,
|
||||||
'color': color.value,
|
'color': color.value,
|
||||||
'position': {'x': position.dx, 'y': position.dy},
|
'position': {'x': position.dx, 'y': position.dy},
|
||||||
'scale': scale,
|
'scale': scale,
|
||||||
|
|
@ -42,9 +57,13 @@ class QuipTextOverlay {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
factory QuipTextOverlay.fromJson(Map<String, dynamic> json) {
|
factory QuipOverlayItem.fromJson(Map<String, dynamic> json) {
|
||||||
return QuipTextOverlay(
|
return QuipOverlayItem(
|
||||||
text: json['text'] as String,
|
id: json['id'] as String? ?? UniqueKey().toString(),
|
||||||
|
type: QuipOverlayType.values.byName(
|
||||||
|
(json['type'] as String?) ?? 'text',
|
||||||
|
),
|
||||||
|
content: (json['content'] ?? json['text'] ?? '') as String,
|
||||||
color: Color(json['color'] as int),
|
color: Color(json['color'] as int),
|
||||||
position: Offset(
|
position: Offset(
|
||||||
(json['position']['x'] as num).toDouble(),
|
(json['position']['x'] as num).toDouble(),
|
||||||
|
|
@ -56,7 +75,11 @@ class QuipTextOverlay {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Placeholder for future music track functionality
|
/// Backward-compat alias so existing screens that reference QuipTextOverlay
|
||||||
|
/// do not require immediate migration.
|
||||||
|
typedef QuipTextOverlay = QuipOverlayItem;
|
||||||
|
|
||||||
|
/// Placeholder for music track metadata.
|
||||||
class MusicTrack {
|
class MusicTrack {
|
||||||
final String id;
|
final String id;
|
||||||
final String name;
|
final String name;
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,12 @@ class QuipUploadNotifier extends Notifier<QuipUploadState> {
|
||||||
return QuipUploadState(isUploading: false, progress: 0.0);
|
return QuipUploadState(isUploading: false, progress: 0.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> startUpload(File videoFile, String caption, {double? thumbnailTimestampMs}) async {
|
Future<void> startUpload(
|
||||||
|
File videoFile,
|
||||||
|
String caption, {
|
||||||
|
double? thumbnailTimestampMs,
|
||||||
|
String? overlayJson,
|
||||||
|
}) async {
|
||||||
try {
|
try {
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
isUploading: true, progress: 0.0, error: null, successMessage: null);
|
isUploading: true, progress: 0.0, error: null, successMessage: null);
|
||||||
|
|
@ -105,10 +110,11 @@ class QuipUploadNotifier extends Notifier<QuipUploadState> {
|
||||||
|
|
||||||
// Publish post via Go API
|
// Publish post via Go API
|
||||||
await ApiService.instance.publishPost(
|
await ApiService.instance.publishPost(
|
||||||
body: caption,
|
body: caption.isNotEmpty ? caption : ' ',
|
||||||
videoUrl: videoUrl,
|
videoUrl: videoUrl,
|
||||||
thumbnailUrl: thumbnailUrl,
|
thumbnailUrl: thumbnailUrl,
|
||||||
categoryId: null, // Default
|
categoryId: null, // Default
|
||||||
|
overlayJson: overlayJson,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Trigger feed refresh
|
// Trigger feed refresh
|
||||||
|
|
|
||||||
|
|
@ -171,7 +171,7 @@ class BeaconScreenState extends ConsumerState<BeaconScreen> with TickerProviderS
|
||||||
if (!_locationPermissionGranted) return;
|
if (!_locationPermissionGranted) return;
|
||||||
setState(() => _isLoadingLocation = true);
|
setState(() => _isLoadingLocation = true);
|
||||||
try {
|
try {
|
||||||
final position = await Geolocator.getCurrentPosition(desiredAccuracy: LocationAccuracy.high);
|
final position = await Geolocator.getCurrentPosition(desiredAccuracy: LocationAccuracy.low);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_userLocation = LatLng(position.latitude, position.longitude);
|
_userLocation = LatLng(position.latitude, position.longitude);
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import '../../widgets/radial_menu_overlay.dart';
|
import '../../widgets/radial_menu_overlay.dart';
|
||||||
import '../../widgets/onboarding_modal.dart';
|
import '../../widgets/onboarding_modal.dart';
|
||||||
import '../../widgets/offline_indicator.dart';
|
import '../../widgets/offline_indicator.dart';
|
||||||
|
import '../../widgets/neighborhood/neighborhood_picker_sheet.dart';
|
||||||
|
import '../../services/api_service.dart';
|
||||||
import '../../providers/quip_upload_provider.dart';
|
import '../../providers/quip_upload_provider.dart';
|
||||||
import '../../providers/notification_provider.dart';
|
import '../../providers/notification_provider.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
@ -61,7 +63,10 @@ class _HomeShellState extends ConsumerState<HomeShell> with WidgetsBindingObserv
|
||||||
_initNotificationListener();
|
_initNotificationListener();
|
||||||
_loadNavTapCounts();
|
_loadNavTapCounts();
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (mounted) OnboardingModal.showIfNeeded(context);
|
if (mounted) {
|
||||||
|
OnboardingModal.showIfNeeded(context);
|
||||||
|
_checkNeighborhoodOnboarding();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -87,6 +92,23 @@ class _HomeShellState extends ConsumerState<HomeShell> with WidgetsBindingObserv
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _checkNeighborhoodOnboarding() async {
|
||||||
|
try {
|
||||||
|
final data = await ApiService.instance.getMyNeighborhood();
|
||||||
|
if (data == null) return;
|
||||||
|
final onboarded = data['onboarded'] as bool? ?? false;
|
||||||
|
if (!onboarded && mounted) {
|
||||||
|
// Small delay so the onboarding modal (if shown) has time to appear first
|
||||||
|
await Future.delayed(const Duration(milliseconds: 800));
|
||||||
|
if (mounted) {
|
||||||
|
await NeighborhoodPickerSheet.show(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Non-critical — silently ignore if network unavailable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _initNotificationListener() {
|
void _initNotificationListener() {
|
||||||
_notifSub = NotificationService.instance.foregroundMessages.listen((message) {
|
_notifSub = NotificationService.instance.foregroundMessages.listen((message) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
|
|
|
||||||
385
sojorn_app/lib/screens/quips/create/quip_camera_screen.dart
Normal file
385
sojorn_app/lib/screens/quips/create/quip_camera_screen.dart
Normal file
|
|
@ -0,0 +1,385 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:camera/camera.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
|
|
||||||
|
import '../../../screens/audio/audio_library_screen.dart';
|
||||||
|
import '../../../theme/tokens.dart';
|
||||||
|
import '../../../theme/app_theme.dart';
|
||||||
|
import 'quip_decorate_screen.dart';
|
||||||
|
|
||||||
|
/// Stage 1 of the new Quip creation flow.
|
||||||
|
///
|
||||||
|
/// Full-screen camera preview with:
|
||||||
|
/// - Pre-record sound selection (top-center)
|
||||||
|
/// - Flash + flip camera controls (top-right)
|
||||||
|
/// - 10 s progress-ring record button (bottom-center)
|
||||||
|
/// Tap = start/stop toggle; Hold = hold-to-record
|
||||||
|
///
|
||||||
|
/// On stop (or auto-stop at 10 s), navigates to [QuipDecorateScreen].
|
||||||
|
class QuipCameraScreen extends StatefulWidget {
|
||||||
|
const QuipCameraScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<QuipCameraScreen> createState() => _QuipCameraScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _QuipCameraScreenState extends State<QuipCameraScreen>
|
||||||
|
with WidgetsBindingObserver {
|
||||||
|
static const Duration _maxDuration = Duration(seconds: 10);
|
||||||
|
static const Duration _tickInterval = Duration(milliseconds: 30);
|
||||||
|
|
||||||
|
// Camera
|
||||||
|
List<CameraDescription> _cameras = [];
|
||||||
|
CameraController? _cameraController;
|
||||||
|
bool _isRearCamera = true;
|
||||||
|
bool _isInitializing = true;
|
||||||
|
bool _flashOn = false;
|
||||||
|
|
||||||
|
// Recording
|
||||||
|
bool _isRecording = false;
|
||||||
|
double _progress = 0.0; // 0.0–1.0
|
||||||
|
Timer? _progressTicker;
|
||||||
|
Timer? _autoStopTimer;
|
||||||
|
DateTime? _recordStart;
|
||||||
|
|
||||||
|
// Pre-record audio
|
||||||
|
AudioTrack? _selectedAudio;
|
||||||
|
|
||||||
|
// Processing (brief moment between stop and navigate)
|
||||||
|
bool _isProcessing = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addObserver(this);
|
||||||
|
_initCamera();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
|
_progressTicker?.cancel();
|
||||||
|
_autoStopTimer?.cancel();
|
||||||
|
_cameraController?.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
|
if (_cameraController == null ||
|
||||||
|
!_cameraController!.value.isInitialized) return;
|
||||||
|
if (state == AppLifecycleState.inactive) {
|
||||||
|
_cameraController?.dispose();
|
||||||
|
} else if (state == AppLifecycleState.resumed) {
|
||||||
|
_initCamera();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Camera init ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Future<void> _initCamera() async {
|
||||||
|
setState(() => _isInitializing = true);
|
||||||
|
|
||||||
|
if (!kIsWeb) {
|
||||||
|
final status =
|
||||||
|
await [Permission.camera, Permission.microphone].request();
|
||||||
|
if (status[Permission.camera] != PermissionStatus.granted ||
|
||||||
|
status[Permission.microphone] != PermissionStatus.granted) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Camera & microphone access required')),
|
||||||
|
);
|
||||||
|
Navigator.pop(context);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
_cameras = await availableCameras();
|
||||||
|
if (_cameras.isEmpty) throw Exception('No cameras found');
|
||||||
|
|
||||||
|
final camera = _cameras.firstWhere(
|
||||||
|
(c) => c.lensDirection ==
|
||||||
|
(_isRearCamera
|
||||||
|
? CameraLensDirection.back
|
||||||
|
: CameraLensDirection.front),
|
||||||
|
orElse: () => _cameras.first,
|
||||||
|
);
|
||||||
|
|
||||||
|
_cameraController = CameraController(
|
||||||
|
camera,
|
||||||
|
ResolutionPreset.high,
|
||||||
|
enableAudio: true,
|
||||||
|
imageFormatGroup: ImageFormatGroup.yuv420,
|
||||||
|
);
|
||||||
|
|
||||||
|
await _cameraController!.initialize();
|
||||||
|
await _cameraController!.prepareForVideoRecording();
|
||||||
|
if (mounted) setState(() => _isInitializing = false);
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) setState(() => _isInitializing = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _toggleCamera() async {
|
||||||
|
if (_isRecording) return;
|
||||||
|
setState(() {
|
||||||
|
_isRearCamera = !_isRearCamera;
|
||||||
|
_isInitializing = true;
|
||||||
|
});
|
||||||
|
await _cameraController?.dispose();
|
||||||
|
_cameraController = null;
|
||||||
|
_initCamera();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _toggleFlash() async {
|
||||||
|
if (_cameraController == null) return;
|
||||||
|
try {
|
||||||
|
_flashOn = !_flashOn;
|
||||||
|
await _cameraController!
|
||||||
|
.setFlashMode(_flashOn ? FlashMode.torch : FlashMode.off);
|
||||||
|
setState(() {});
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Audio ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Future<void> _pickSound() async {
|
||||||
|
final track = await Navigator.push<AudioTrack>(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (_) => const AudioLibraryScreen()),
|
||||||
|
);
|
||||||
|
if (track != null && mounted) {
|
||||||
|
setState(() => _selectedAudio = track);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Recording ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Future<void> _startRecording() async {
|
||||||
|
if (_cameraController == null ||
|
||||||
|
!_cameraController!.value.isInitialized ||
|
||||||
|
_isRecording) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _cameraController!.startVideoRecording();
|
||||||
|
_recordStart = DateTime.now();
|
||||||
|
_autoStopTimer = Timer(_maxDuration, _stopRecording);
|
||||||
|
_progressTicker =
|
||||||
|
Timer.periodic(_tickInterval, (_) => _updateProgress());
|
||||||
|
if (mounted) setState(() => _isRecording = true);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _updateProgress() {
|
||||||
|
if (!mounted || _recordStart == null) return;
|
||||||
|
final elapsed = DateTime.now().difference(_recordStart!);
|
||||||
|
setState(() {
|
||||||
|
_progress =
|
||||||
|
(elapsed.inMilliseconds / _maxDuration.inMilliseconds).clamp(0.0, 1.0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _stopRecording() async {
|
||||||
|
if (!_isRecording) return;
|
||||||
|
_progressTicker?.cancel();
|
||||||
|
_autoStopTimer?.cancel();
|
||||||
|
|
||||||
|
try {
|
||||||
|
final xfile = await _cameraController!.stopVideoRecording();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_isRecording = false;
|
||||||
|
_progress = 0.0;
|
||||||
|
_isProcessing = true;
|
||||||
|
});
|
||||||
|
await _cameraController?.pausePreview();
|
||||||
|
final videoFile = File(xfile.path);
|
||||||
|
if (mounted) {
|
||||||
|
await Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => QuipDecorateScreen(
|
||||||
|
videoFile: videoFile,
|
||||||
|
preloadedAudio: _selectedAudio,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await _cameraController?.resumePreview();
|
||||||
|
if (mounted) setState(() => _isProcessing = false);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
if (mounted) setState(() {_isRecording = false; _progress = 0.0; _isProcessing = false;});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onRecordTap() {
|
||||||
|
if (_isRecording) {
|
||||||
|
_stopRecording();
|
||||||
|
} else {
|
||||||
|
_startRecording();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Build ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (_isInitializing || _cameraController == null) {
|
||||||
|
return const Scaffold(
|
||||||
|
backgroundColor: SojornColors.basicBlack,
|
||||||
|
body: Center(child: CircularProgressIndicator(color: SojornColors.basicWhite)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: SojornColors.basicBlack,
|
||||||
|
body: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
// Full-screen camera preview
|
||||||
|
CameraPreview(_cameraController!),
|
||||||
|
|
||||||
|
// Processing overlay
|
||||||
|
if (_isProcessing)
|
||||||
|
const ColoredBox(
|
||||||
|
color: Color(0x88000000),
|
||||||
|
child: Center(child: CircularProgressIndicator(color: SojornColors.basicWhite)),
|
||||||
|
),
|
||||||
|
|
||||||
|
// ── Top bar ──────────────────────────────────────────────────────
|
||||||
|
SafeArea(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
// Close
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.close, color: SojornColors.basicWhite),
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
),
|
||||||
|
// Add Sound (center)
|
||||||
|
Expanded(
|
||||||
|
child: Center(
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: _pickSound,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(color: SojornColors.basicWhite.withValues(alpha: 0.7)),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.music_note, color: SojornColors.basicWhite, size: 16),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
_selectedAudio != null
|
||||||
|
? _selectedAudio!.title
|
||||||
|
: 'Add Sound',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: SojornColors.basicWhite,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Flash + Flip
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
_flashOn ? Icons.flash_on : Icons.flash_off,
|
||||||
|
color: SojornColors.basicWhite,
|
||||||
|
),
|
||||||
|
onPressed: _toggleFlash,
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.flip_camera_ios, color: SojornColors.basicWhite),
|
||||||
|
onPressed: _toggleCamera,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// ── Record button (bottom-center) ─────────────────────────────────
|
||||||
|
Positioned(
|
||||||
|
bottom: 56,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
child: Center(child: _buildRecordButton()),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildRecordButton() {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: _onRecordTap,
|
||||||
|
onLongPress: _startRecording,
|
||||||
|
onLongPressUp: _stopRecording,
|
||||||
|
child: SizedBox(
|
||||||
|
width: 88,
|
||||||
|
height: 88,
|
||||||
|
child: Stack(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
children: [
|
||||||
|
// Progress ring
|
||||||
|
SizedBox(
|
||||||
|
width: 88,
|
||||||
|
height: 88,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
value: _isRecording ? _progress : 0.0,
|
||||||
|
strokeWidth: 4,
|
||||||
|
backgroundColor: SojornColors.basicWhite.withValues(alpha: 0.3),
|
||||||
|
valueColor:
|
||||||
|
const AlwaysStoppedAnimation<Color>(SojornColors.destructive),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Inner solid circle (slightly smaller)
|
||||||
|
Container(
|
||||||
|
width: 68,
|
||||||
|
height: 68,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _isRecording
|
||||||
|
? SojornColors.destructive
|
||||||
|
: SojornColors.destructive,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(
|
||||||
|
color: SojornColors.basicWhite,
|
||||||
|
width: _isRecording ? 0 : 3,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: _isRecording
|
||||||
|
? const Icon(Icons.stop_rounded,
|
||||||
|
color: SojornColors.basicWhite, size: 32)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'quip_recorder_screen.dart';
|
import 'quip_camera_screen.dart';
|
||||||
|
|
||||||
/// Entry point wrapper for the Quip Creation Flow.
|
/// Entry point wrapper for the Quip Creation Flow.
|
||||||
/// Navigation is now handled linearly starting from [QuipRecorderScreen].
|
/// Routes to [QuipCameraScreen] — the new Snapchat-style camera with
|
||||||
|
/// instant sticker/text decoration and zero encoding wait.
|
||||||
class QuipCreationFlow extends StatelessWidget {
|
class QuipCreationFlow extends StatelessWidget {
|
||||||
const QuipCreationFlow({super.key});
|
const QuipCreationFlow({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return const QuipRecorderScreen();
|
return const QuipCameraScreen();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
593
sojorn_app/lib/screens/quips/create/quip_decorate_screen.dart
Normal file
593
sojorn_app/lib/screens/quips/create/quip_decorate_screen.dart
Normal file
|
|
@ -0,0 +1,593 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:video_player/video_player.dart';
|
||||||
|
|
||||||
|
import '../../../models/quip_text_overlay.dart';
|
||||||
|
import '../../../providers/quip_upload_provider.dart';
|
||||||
|
import '../../../screens/audio/audio_library_screen.dart';
|
||||||
|
import '../../../theme/tokens.dart';
|
||||||
|
import '../../../theme/app_theme.dart';
|
||||||
|
|
||||||
|
// Curated sticker/emoji set for the picker
|
||||||
|
const _kTextStickers = ['LOL', 'OMG', 'WOW', 'WAIT', 'FR?', 'NO WAY'];
|
||||||
|
const _kEmojis = [
|
||||||
|
'🎉', '🔥', '❤️', '😂', '💯', '✨',
|
||||||
|
'🤣', '😍', '🙌', '😮', '💕', '🤩',
|
||||||
|
'🎶', '🌟', '💀', '😎', '🥰', '🤔',
|
||||||
|
'👀', '🫶',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Colors available for text overlays
|
||||||
|
const _kTextColors = [
|
||||||
|
Colors.white,
|
||||||
|
Colors.yellow,
|
||||||
|
Colors.cyan,
|
||||||
|
Colors.pinkAccent,
|
||||||
|
Colors.greenAccent,
|
||||||
|
Colors.redAccent,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Stage 2 of the new Quip creation flow.
|
||||||
|
///
|
||||||
|
/// The raw video loops immediately. The user decorates with:
|
||||||
|
/// - Draggable + pinch-to-scale/rotate text and sticker overlays
|
||||||
|
/// - Pre-recorded or newly-selected background audio
|
||||||
|
/// - A "Post Quip" FAB that fires a background upload and returns to the feed
|
||||||
|
class QuipDecorateScreen extends ConsumerStatefulWidget {
|
||||||
|
final File videoFile;
|
||||||
|
final AudioTrack? preloadedAudio;
|
||||||
|
|
||||||
|
const QuipDecorateScreen({
|
||||||
|
super.key,
|
||||||
|
required this.videoFile,
|
||||||
|
this.preloadedAudio,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<QuipDecorateScreen> createState() => _QuipDecorateScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _QuipDecorateScreenState extends ConsumerState<QuipDecorateScreen> {
|
||||||
|
late VideoPlayerController _controller;
|
||||||
|
bool _videoReady = false;
|
||||||
|
|
||||||
|
// Overlays
|
||||||
|
final List<_EditableOverlay> _overlays = [];
|
||||||
|
String? _draggingId; // id of the item being dragged/scaled
|
||||||
|
|
||||||
|
// Trash zone
|
||||||
|
bool _showTrash = false;
|
||||||
|
bool _overTrash = false;
|
||||||
|
|
||||||
|
// Audio
|
||||||
|
AudioTrack? _selectedAudio;
|
||||||
|
|
||||||
|
// Text color for next text item
|
||||||
|
Color _nextTextColor = Colors.white;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_selectedAudio = widget.preloadedAudio;
|
||||||
|
_initVideo();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _initVideo() async {
|
||||||
|
_controller = VideoPlayerController.file(widget.videoFile);
|
||||||
|
await _controller.initialize();
|
||||||
|
_controller.setLooping(true);
|
||||||
|
_controller.play();
|
||||||
|
if (mounted) setState(() => _videoReady = true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Overlay management ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
String _newId() => DateTime.now().microsecondsSinceEpoch.toString();
|
||||||
|
|
||||||
|
void _addTextOverlay(String text) {
|
||||||
|
if (text.trim().isEmpty) return;
|
||||||
|
setState(() {
|
||||||
|
_overlays.add(_EditableOverlay(
|
||||||
|
id: _newId(),
|
||||||
|
type: QuipOverlayType.text,
|
||||||
|
content: text.trim(),
|
||||||
|
color: _nextTextColor,
|
||||||
|
normalizedX: 0.5,
|
||||||
|
normalizedY: 0.4,
|
||||||
|
scale: 1.0,
|
||||||
|
rotation: 0.0,
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _addStickerOverlay(String sticker) {
|
||||||
|
setState(() {
|
||||||
|
_overlays.add(_EditableOverlay(
|
||||||
|
id: _newId(),
|
||||||
|
type: QuipOverlayType.sticker,
|
||||||
|
content: sticker,
|
||||||
|
color: Colors.white,
|
||||||
|
normalizedX: 0.5,
|
||||||
|
normalizedY: 0.5,
|
||||||
|
scale: 1.0,
|
||||||
|
rotation: 0.0,
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _removeOverlay(String id) {
|
||||||
|
setState(() => _overlays.removeWhere((o) => o.id == id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Actions ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
void _openTextSheet() {
|
||||||
|
final ctrl = TextEditingController();
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: const Color(0xDD000000),
|
||||||
|
builder: (ctx) => Padding(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
bottom: MediaQuery.of(ctx).viewInsets.bottom + 16,
|
||||||
|
left: 16,
|
||||||
|
right: 16,
|
||||||
|
top: 16,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Color row
|
||||||
|
Row(
|
||||||
|
children: _kTextColors.map((c) {
|
||||||
|
final selected = c == _nextTextColor;
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => setState(() => _nextTextColor = c),
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.only(right: 8),
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: c,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: selected
|
||||||
|
? Border.all(color: SojornColors.basicWhite, width: 2)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: ctrl,
|
||||||
|
autofocus: true,
|
||||||
|
style: const TextStyle(color: SojornColors.basicWhite, fontSize: 22),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
border: InputBorder.none,
|
||||||
|
hintText: 'Type something...',
|
||||||
|
hintStyle: TextStyle(color: SojornColors.basicWhite.withValues(alpha: 0.4)),
|
||||||
|
),
|
||||||
|
onSubmitted: (val) {
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
_addTextOverlay(val);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
_addTextOverlay(ctrl.text);
|
||||||
|
},
|
||||||
|
child: const Text('Done', style: TextStyle(color: SojornColors.basicWhite, fontSize: 16)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _openStickerSheet() {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
backgroundColor: const Color(0xDD000000),
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||||
|
),
|
||||||
|
builder: (ctx) => SafeArea(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Text stickers row
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||||
|
child: Wrap(
|
||||||
|
spacing: 10,
|
||||||
|
runSpacing: 10,
|
||||||
|
children: _kTextStickers.map((s) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
_addStickerOverlay(s);
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(color: SojornColors.basicWhite, width: 1.5),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Text(s,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: SojornColors.basicWhite,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 14)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Emoji grid
|
||||||
|
SizedBox(
|
||||||
|
height: 180,
|
||||||
|
child: GridView.count(
|
||||||
|
crossAxisCount: 7,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
children: _kEmojis.map((e) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
_addStickerOverlay(e);
|
||||||
|
},
|
||||||
|
child: Center(
|
||||||
|
child: Text(e, style: const TextStyle(fontSize: 28)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pickSound() async {
|
||||||
|
final track = await Navigator.push<AudioTrack>(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (_) => const AudioLibraryScreen()),
|
||||||
|
);
|
||||||
|
if (track != null && mounted) {
|
||||||
|
setState(() => _selectedAudio = track);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _postQuip() async {
|
||||||
|
_controller.pause();
|
||||||
|
|
||||||
|
// Build overlay + sound JSON payload
|
||||||
|
final payload = {
|
||||||
|
'overlays': _overlays.map((o) => o.toJson()).toList(),
|
||||||
|
if (_selectedAudio != null) 'sound_id': _selectedAudio!.path,
|
||||||
|
};
|
||||||
|
final overlayJson = jsonEncode(payload);
|
||||||
|
|
||||||
|
ref.read(quipUploadProvider.notifier).startUpload(
|
||||||
|
widget.videoFile,
|
||||||
|
'',
|
||||||
|
overlayJson: overlayJson,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
Navigator.of(context).popUntil((route) => route.isFirst);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Uploading your Quip...')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Build ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (!_videoReady) {
|
||||||
|
return const Scaffold(
|
||||||
|
backgroundColor: SojornColors.basicBlack,
|
||||||
|
body: Center(child: CircularProgressIndicator(color: SojornColors.basicWhite)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: SojornColors.basicBlack,
|
||||||
|
body: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final w = constraints.maxWidth;
|
||||||
|
final h = constraints.maxHeight;
|
||||||
|
|
||||||
|
return Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
// 1. Looping video
|
||||||
|
Center(
|
||||||
|
child: FittedBox(
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
child: SizedBox(
|
||||||
|
width: _controller.value.size.width,
|
||||||
|
height: _controller.value.size.height,
|
||||||
|
child: VideoPlayer(_controller),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// 2. Overlay items (draggable, pinch-to-scale/rotate)
|
||||||
|
..._overlays.map((o) => _buildOverlayWidget(o, w, h)),
|
||||||
|
|
||||||
|
// 3. Trash zone (shown while dragging)
|
||||||
|
if (_showTrash)
|
||||||
|
Positioned(
|
||||||
|
bottom: 40,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
child: Center(
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 150),
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _overTrash
|
||||||
|
? SojornColors.destructive
|
||||||
|
: const Color(0xAA000000),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.delete_outline,
|
||||||
|
color: SojornColors.basicWhite,
|
||||||
|
size: _overTrash ? 40 : 32,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// 4. Top-left back button
|
||||||
|
SafeArea(
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.topLeft,
|
||||||
|
child: IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back, color: SojornColors.basicWhite),
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// 5. Right sidebar (Text, Sticker, Sound)
|
||||||
|
Positioned(
|
||||||
|
right: 16,
|
||||||
|
top: 100,
|
||||||
|
child: SafeArea(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
_buildSideButton(Icons.text_fields, 'Text', _openTextSheet),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
_buildSideButton(Icons.emoji_emotions_outlined, 'Sticker', _openStickerSheet),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
_buildSideButton(
|
||||||
|
_selectedAudio != null ? Icons.music_note : Icons.music_note_outlined,
|
||||||
|
_selectedAudio != null ? 'Sound ✓' : 'Sound',
|
||||||
|
_pickSound,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// 6. "Post Quip" FAB (bottom-right)
|
||||||
|
Positioned(
|
||||||
|
bottom: 40,
|
||||||
|
right: 20,
|
||||||
|
child: FloatingActionButton.extended(
|
||||||
|
backgroundColor: AppTheme.brightNavy,
|
||||||
|
onPressed: _postQuip,
|
||||||
|
icon: const Icon(Icons.send_rounded, color: SojornColors.basicWhite),
|
||||||
|
label: const Text(
|
||||||
|
'Post Quip',
|
||||||
|
style: TextStyle(color: SojornColors.basicWhite, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildOverlayWidget(_EditableOverlay overlay, double w, double h) {
|
||||||
|
final absX = overlay.normalizedX * w;
|
||||||
|
final absY = overlay.normalizedY * h;
|
||||||
|
final isText = overlay.type == QuipOverlayType.text;
|
||||||
|
|
||||||
|
return Positioned(
|
||||||
|
left: absX - 60, // rough half-width offset so item centers on position
|
||||||
|
top: absY - 30,
|
||||||
|
child: GestureDetector(
|
||||||
|
onScaleStart: (_) {
|
||||||
|
setState(() {
|
||||||
|
_draggingId = overlay.id;
|
||||||
|
_showTrash = true;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onScaleUpdate: (details) {
|
||||||
|
final idx = _overlays.indexWhere((o) => o.id == overlay.id);
|
||||||
|
if (idx == -1) return;
|
||||||
|
|
||||||
|
// Convert global focal point to normalized position
|
||||||
|
final newNX = (details.focalPoint.dx / w).clamp(0.0, 1.0);
|
||||||
|
final newNY = (details.focalPoint.dy / h).clamp(0.0, 1.0);
|
||||||
|
|
||||||
|
// Detect if over trash zone (bottom 80px)
|
||||||
|
final overTrash = details.focalPoint.dy > h - 80;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_overTrash = overTrash;
|
||||||
|
_overlays[idx] = _overlays[idx].copyWith(
|
||||||
|
normalizedX: newNX,
|
||||||
|
normalizedY: newNY,
|
||||||
|
scale: (_overlays[idx].scale * details.scale).clamp(0.3, 5.0),
|
||||||
|
rotation: _overlays[idx].rotation + details.rotation,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onScaleEnd: (_) {
|
||||||
|
if (_overTrash && _draggingId != null) {
|
||||||
|
_removeOverlay(_draggingId!);
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_draggingId = null;
|
||||||
|
_showTrash = false;
|
||||||
|
_overTrash = false;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: Transform(
|
||||||
|
transform: Matrix4.identity()
|
||||||
|
..scale(overlay.scale)
|
||||||
|
..rotateZ(overlay.rotation),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: isText
|
||||||
|
? _buildTextChip(overlay)
|
||||||
|
: _buildStickerChip(overlay),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTextChip(_EditableOverlay overlay) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withValues(alpha: 0.4),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
overlay.content,
|
||||||
|
style: TextStyle(
|
||||||
|
color: overlay.color,
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
shadows: const [Shadow(blurRadius: 4, color: Colors.black)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStickerChip(_EditableOverlay overlay) {
|
||||||
|
final isEmoji = overlay.content.runes.length == 1 ||
|
||||||
|
overlay.content.length <= 2;
|
||||||
|
if (isEmoji) {
|
||||||
|
return Text(overlay.content, style: const TextStyle(fontSize: 48));
|
||||||
|
}
|
||||||
|
// Text sticker ('LOL', 'OMG', etc.)
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(color: SojornColors.basicWhite, width: 2),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
color: Colors.black.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
overlay.content,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: SojornColors.basicWhite,
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSideButton(IconData icon, String label, VoidCallback onTap) {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
child: CircleAvatar(
|
||||||
|
backgroundColor: const Color(0x8A000000),
|
||||||
|
radius: 24,
|
||||||
|
child: Icon(icon, color: SojornColors.basicWhite, size: 26),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: SojornColors.basicWhite,
|
||||||
|
fontSize: 11,
|
||||||
|
shadows: [Shadow(blurRadius: 2, color: Colors.black)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Internal mutable overlay state ──────────────────────────────────────────
|
||||||
|
|
||||||
|
class _EditableOverlay {
|
||||||
|
final String id;
|
||||||
|
final QuipOverlayType type;
|
||||||
|
final String content;
|
||||||
|
final Color color;
|
||||||
|
double normalizedX;
|
||||||
|
double normalizedY;
|
||||||
|
double scale;
|
||||||
|
double rotation;
|
||||||
|
|
||||||
|
_EditableOverlay({
|
||||||
|
required this.id,
|
||||||
|
required this.type,
|
||||||
|
required this.content,
|
||||||
|
required this.color,
|
||||||
|
required this.normalizedX,
|
||||||
|
required this.normalizedY,
|
||||||
|
required this.scale,
|
||||||
|
required this.rotation,
|
||||||
|
});
|
||||||
|
|
||||||
|
_EditableOverlay copyWith({
|
||||||
|
double? normalizedX,
|
||||||
|
double? normalizedY,
|
||||||
|
double? scale,
|
||||||
|
double? rotation,
|
||||||
|
}) {
|
||||||
|
return _EditableOverlay(
|
||||||
|
id: id,
|
||||||
|
type: type,
|
||||||
|
content: content,
|
||||||
|
color: color,
|
||||||
|
normalizedX: normalizedX ?? this.normalizedX,
|
||||||
|
normalizedY: normalizedY ?? this.normalizedY,
|
||||||
|
scale: scale ?? this.scale,
|
||||||
|
rotation: rotation ?? this.rotation,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'id': id,
|
||||||
|
'type': type.name,
|
||||||
|
'content': content,
|
||||||
|
'color': color.value,
|
||||||
|
'position': {'x': normalizedX, 'y': normalizedY},
|
||||||
|
'scale': scale,
|
||||||
|
'rotation': rotation,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:video_player/video_player.dart';
|
import 'package:video_player/video_player.dart';
|
||||||
|
import '../../../models/quip_text_overlay.dart';
|
||||||
import '../../../widgets/media/signed_media_image.dart';
|
import '../../../widgets/media/signed_media_image.dart';
|
||||||
import '../../../widgets/video_player_with_comments.dart';
|
import '../../../widgets/video_player_with_comments.dart';
|
||||||
import '../../../models/post.dart';
|
import '../../../models/post.dart';
|
||||||
|
|
@ -194,6 +197,81 @@ class QuipVideoItem extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parses overlay_json and returns a list of non-interactive overlay widgets
|
||||||
|
/// rendered on top of the video during feed playback.
|
||||||
|
List<Widget> _buildOverlayWidgets(BoxConstraints constraints) {
|
||||||
|
final json = quip.overlayJson;
|
||||||
|
if (json == null || json.isEmpty) return [];
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(json) as Map<String, dynamic>;
|
||||||
|
final items = (decoded['overlays'] as List<dynamic>? ?? [])
|
||||||
|
.whereType<Map<String, dynamic>>()
|
||||||
|
.map(QuipOverlayItem.fromJson)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
final w = constraints.maxWidth;
|
||||||
|
final h = constraints.maxHeight;
|
||||||
|
|
||||||
|
return items.map((item) {
|
||||||
|
final absX = item.position.dx * w;
|
||||||
|
final absY = item.position.dy * h;
|
||||||
|
final isSticker = item.type == QuipOverlayType.sticker;
|
||||||
|
|
||||||
|
Widget child;
|
||||||
|
if (isSticker) {
|
||||||
|
final isEmoji = item.content.runes.length == 1 ||
|
||||||
|
item.content.length <= 2;
|
||||||
|
if (isEmoji) {
|
||||||
|
child = Text(item.content,
|
||||||
|
style: TextStyle(fontSize: 42 * item.scale));
|
||||||
|
} else {
|
||||||
|
child = Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(color: SojornColors.basicWhite, width: 2),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
color: Colors.black.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
item.content,
|
||||||
|
style: TextStyle(
|
||||||
|
color: SojornColors.basicWhite,
|
||||||
|
fontSize: 20 * item.scale,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
child = Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withValues(alpha: 0.4),
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
item.content,
|
||||||
|
style: TextStyle(
|
||||||
|
color: item.color,
|
||||||
|
fontSize: 24 * item.scale,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
shadows: const [Shadow(blurRadius: 4, color: Colors.black)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Positioned(
|
||||||
|
left: absX - 50,
|
||||||
|
top: absY - 20,
|
||||||
|
child: Transform.rotate(angle: item.rotation, child: child),
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
} catch (_) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildPauseOverlay() {
|
Widget _buildPauseOverlay() {
|
||||||
if (!isActive || !isUserPaused) return const SizedBox.shrink();
|
if (!isActive || !isUserPaused) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
|
@ -219,7 +297,8 @@ class QuipVideoItem extends StatelessWidget {
|
||||||
onTap: onTogglePause,
|
onTap: onTogglePause,
|
||||||
child: Container(
|
child: Container(
|
||||||
color: SojornColors.basicBlack,
|
color: SojornColors.basicBlack,
|
||||||
child: Stack(
|
child: LayoutBuilder(
|
||||||
|
builder: (context, constraints) => Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
AnimatedOpacity(
|
AnimatedOpacity(
|
||||||
|
|
@ -227,6 +306,8 @@ class QuipVideoItem extends StatelessWidget {
|
||||||
opacity: isActive ? 1 : 0.6,
|
opacity: isActive ? 1 : 0.6,
|
||||||
child: _buildVideo(),
|
child: _buildVideo(),
|
||||||
),
|
),
|
||||||
|
// Quip overlays (text + stickers, non-interactive in feed)
|
||||||
|
..._buildOverlayWidgets(constraints),
|
||||||
Container(
|
Container(
|
||||||
decoration: const BoxDecoration(
|
decoration: const BoxDecoration(
|
||||||
gradient: LinearGradient(
|
gradient: LinearGradient(
|
||||||
|
|
@ -303,6 +384,7 @@ class QuipVideoItem extends StatelessWidget {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ class Quip {
|
||||||
final String? avatarUrl;
|
final String? avatarUrl;
|
||||||
final int? durationMs;
|
final int? durationMs;
|
||||||
final int? likeCount;
|
final int? likeCount;
|
||||||
|
final String? overlayJson;
|
||||||
|
|
||||||
const Quip({
|
const Quip({
|
||||||
required this.id,
|
required this.id,
|
||||||
|
|
@ -35,6 +36,7 @@ class Quip {
|
||||||
this.avatarUrl,
|
this.avatarUrl,
|
||||||
this.durationMs,
|
this.durationMs,
|
||||||
this.likeCount,
|
this.likeCount,
|
||||||
|
this.overlayJson,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory Quip.fromMap(Map<String, dynamic> map) {
|
factory Quip.fromMap(Map<String, dynamic> map) {
|
||||||
|
|
@ -53,6 +55,7 @@ class Quip {
|
||||||
avatarUrl: author?['avatar_url'] as String?,
|
avatarUrl: author?['avatar_url'] as String?,
|
||||||
durationMs: map['duration_ms'] as int?,
|
durationMs: map['duration_ms'] as int?,
|
||||||
likeCount: _parseLikeCount(map['metrics']),
|
likeCount: _parseLikeCount(map['metrics']),
|
||||||
|
overlayJson: map['overlay_json'] as String?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -570,6 +570,7 @@ class ApiService {
|
||||||
bool isNsfw = false,
|
bool isNsfw = false,
|
||||||
String? nsfwReason,
|
String? nsfwReason,
|
||||||
String? visibility,
|
String? visibility,
|
||||||
|
String? overlayJson,
|
||||||
}) async {
|
}) async {
|
||||||
// Validate and sanitize inputs
|
// Validate and sanitize inputs
|
||||||
if (body.isEmpty) {
|
if (body.isEmpty) {
|
||||||
|
|
@ -625,6 +626,7 @@ class ApiService {
|
||||||
if (isNsfw) 'is_nsfw': true,
|
if (isNsfw) 'is_nsfw': true,
|
||||||
if (nsfwReason != null) 'nsfw_reason': nsfwReason,
|
if (nsfwReason != null) 'nsfw_reason': nsfwReason,
|
||||||
if (visibility != null) 'visibility': visibility,
|
if (visibility != null) 'visibility': visibility,
|
||||||
|
if (overlayJson != null) 'overlay_json': overlayJson,
|
||||||
},
|
},
|
||||||
requireSignature: true,
|
requireSignature: true,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
@ -66,7 +67,7 @@ class AudioOverlayService {
|
||||||
static Future<File?> pickAudioFile() async {
|
static Future<File?> pickAudioFile() async {
|
||||||
try {
|
try {
|
||||||
// Request storage permission if needed
|
// Request storage permission if needed
|
||||||
if (Platform.isAndroid) {
|
if (!kIsWeb && Platform.isAndroid) {
|
||||||
final status = await Permission.storage.request();
|
final status = await Permission.storage.request();
|
||||||
if (status != PermissionStatus.granted) {
|
if (status != PermissionStatus.granted) {
|
||||||
return null;
|
return null;
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,9 @@ class ImageUploadService {
|
||||||
throw UploadException('Not authenticated. Please sign in again.');
|
throw UploadException('Not authenticated. Please sign in again.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Strip metadata (GPS, device info, timestamps) before upload
|
||||||
|
final sanitized = await MediaSanitizer.sanitizeVideo(videoFile);
|
||||||
|
|
||||||
// Use Go API upload endpoint with R2 integration
|
// Use Go API upload endpoint with R2 integration
|
||||||
final uri = Uri.parse('${ApiConfig.baseUrl}/upload');
|
final uri = Uri.parse('${ApiConfig.baseUrl}/upload');
|
||||||
|
|
||||||
|
|
@ -92,15 +95,14 @@ class ImageUploadService {
|
||||||
request.headers['Authorization'] = 'Bearer $token';
|
request.headers['Authorization'] = 'Bearer $token';
|
||||||
|
|
||||||
// CRITICAL: Use fromPath to stream from disk instead of loading into memory
|
// CRITICAL: Use fromPath to stream from disk instead of loading into memory
|
||||||
final fileLength = await videoFile.length();
|
|
||||||
request.files.add(await http.MultipartFile.fromPath(
|
request.files.add(await http.MultipartFile.fromPath(
|
||||||
'media', // Field name matches upload-media
|
'media', // Field name matches upload-media
|
||||||
videoFile.path,
|
sanitized.path,
|
||||||
contentType: http_parser.MediaType.parse('video/mp4'),
|
contentType: http_parser.MediaType.parse('video/mp4'),
|
||||||
));
|
));
|
||||||
|
|
||||||
request.fields['type'] = 'video';
|
request.fields['type'] = 'video';
|
||||||
request.fields['fileName'] = videoFile.path.split('/').last;
|
request.fields['fileName'] = sanitized.path.split('/').last;
|
||||||
|
|
||||||
onProgress?.call(0.1);
|
onProgress?.call(0.1);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import 'dart:io';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_image_compress/flutter_image_compress.dart';
|
import 'package:flutter_image_compress/flutter_image_compress.dart';
|
||||||
import 'package:image/image.dart' as img;
|
import 'package:image/image.dart' as img;
|
||||||
|
import 'media/ffmpeg.dart';
|
||||||
|
|
||||||
class MediaSanitizer {
|
class MediaSanitizer {
|
||||||
static Future<File> sanitizeImage(File rawFile) async {
|
static Future<File> sanitizeImage(File rawFile) async {
|
||||||
|
|
@ -39,10 +40,6 @@ class MediaSanitizer {
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<File> sanitizeVideo(File rawFile) async {
|
static Future<File> sanitizeVideo(File rawFile) async {
|
||||||
// For videos, we just validate and return the original file
|
|
||||||
// Video processing is handled by the video compression library
|
|
||||||
// This method ensures the file exists and is readable
|
|
||||||
|
|
||||||
if (!await rawFile.exists()) {
|
if (!await rawFile.exists()) {
|
||||||
throw Exception('Video file does not exist');
|
throw Exception('Video file does not exist');
|
||||||
}
|
}
|
||||||
|
|
@ -54,7 +51,6 @@ class MediaSanitizer {
|
||||||
throw Exception('Video size exceeds 50MB limit');
|
throw Exception('Video size exceeds 50MB limit');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if it's a valid video file by extension
|
|
||||||
final fileName = rawFile.path.split('/').last.toLowerCase();
|
final fileName = rawFile.path.split('/').last.toLowerCase();
|
||||||
final extension = fileName.split('.').last;
|
final extension = fileName.split('.').last;
|
||||||
const validExtensions = {'mp4', 'mov', 'webm'};
|
const validExtensions = {'mp4', 'mov', 'webm'};
|
||||||
|
|
@ -63,7 +59,23 @@ class MediaSanitizer {
|
||||||
throw Exception('Unsupported video format: $extension');
|
throw Exception('Unsupported video format: $extension');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return the original file as videos don't need sanitization like images
|
// Strip all metadata (GPS, device info, timestamps) via FFmpeg remux — no re-encode.
|
||||||
|
try {
|
||||||
|
final tempDir = Directory.systemTemp;
|
||||||
|
final output = File(
|
||||||
|
'${tempDir.path}${Platform.pathSeparator}stripped_${DateTime.now().microsecondsSinceEpoch}.mp4',
|
||||||
|
);
|
||||||
|
final session = await FFmpegKit.execute(
|
||||||
|
'-y -i "${rawFile.path}" -map_metadata -1 -c copy "${output.path}"',
|
||||||
|
);
|
||||||
|
final rc = await session.getReturnCode();
|
||||||
|
if (ReturnCode.isSuccess(rc) && await output.exists()) {
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// FFmpeg unavailable — fall through and return original
|
||||||
|
}
|
||||||
|
|
||||||
return rawFile;
|
return rawFile;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,7 @@ class VideoStitchingService {
|
||||||
|
|
||||||
if (segments.length == 1) {
|
if (segments.length == 1) {
|
||||||
// Single video with effects
|
// Single video with effects
|
||||||
command = "-i '${segments.first.path}' $filterString '${outputFile.path}'";
|
command = "-i '${segments.first.path}' $filterString -map_metadata -1 '${outputFile.path}'";
|
||||||
} else {
|
} else {
|
||||||
// Multiple videos - stitch first, then apply effects
|
// Multiple videos - stitch first, then apply effects
|
||||||
final listFile = File('${tempDir.path}/segments_list.txt');
|
final listFile = File('${tempDir.path}/segments_list.txt');
|
||||||
|
|
@ -91,7 +91,7 @@ class VideoStitchingService {
|
||||||
|
|
||||||
final tempStitched = File('${tempDir.path}/temp_stitched.mp4');
|
final tempStitched = File('${tempDir.path}/temp_stitched.mp4');
|
||||||
|
|
||||||
// First stitch without effects
|
// First stitch without effects (metadata stripped at final pass)
|
||||||
final stitchCommand = "-f concat -safe 0 -i '${listFile.path}' -c copy '${tempStitched.path}'";
|
final stitchCommand = "-f concat -safe 0 -i '${listFile.path}' -c copy '${tempStitched.path}'";
|
||||||
final stitchSession = await FFmpegKit.execute(stitchCommand);
|
final stitchSession = await FFmpegKit.execute(stitchCommand);
|
||||||
final stitchReturnCode = await stitchSession.getReturnCode();
|
final stitchReturnCode = await stitchSession.getReturnCode();
|
||||||
|
|
@ -100,8 +100,8 @@ class VideoStitchingService {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Then apply effects to the stitched video
|
// Then apply effects to the stitched video, stripping metadata at final output
|
||||||
command = "-i '${tempStitched.path}' $filterString '${outputFile.path}'";
|
command = "-i '${tempStitched.path}' $filterString -map_metadata -1 '${outputFile.path}'";
|
||||||
}
|
}
|
||||||
|
|
||||||
final session = await FFmpegKit.execute(command);
|
final session = await FFmpegKit.execute(command);
|
||||||
|
|
@ -120,7 +120,7 @@ class VideoStitchingService {
|
||||||
final audioCmd =
|
final audioCmd =
|
||||||
"-i '${outputFile.path}' -i '$audioOverlayPath' "
|
"-i '${outputFile.path}' -i '$audioOverlayPath' "
|
||||||
"-filter_complex '[1:a]volume=${vol}[a1];[0:a][a1]amix=inputs=2:duration=first:dropout_transition=0' "
|
"-filter_complex '[1:a]volume=${vol}[a1];[0:a][a1]amix=inputs=2:duration=first:dropout_transition=0' "
|
||||||
"-c:v copy -shortest '${audioOutputFile.path}'";
|
"-map_metadata -1 -c:v copy -shortest '${audioOutputFile.path}'";
|
||||||
final audioSession = await FFmpegKit.execute(audioCmd);
|
final audioSession = await FFmpegKit.execute(audioCmd);
|
||||||
final audioCode = await audioSession.getReturnCode();
|
final audioCode = await audioSession.getReturnCode();
|
||||||
if (ReturnCode.isSuccess(audioCode)) {
|
if (ReturnCode.isSuccess(audioCode)) {
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,7 @@ class _EnhancedBeaconMapState extends ConsumerState<EnhancedBeaconMap>
|
||||||
Future<void> _getUserLocation() async {
|
Future<void> _getUserLocation() async {
|
||||||
try {
|
try {
|
||||||
final position = await Geolocator.getCurrentPosition(
|
final position = await Geolocator.getCurrentPosition(
|
||||||
desiredAccuracy: LocationAccuracy.high,
|
desiredAccuracy: LocationAccuracy.low,
|
||||||
);
|
);
|
||||||
setState(() {
|
setState(() {
|
||||||
_userLocation = LatLng(position.latitude, position.longitude);
|
_userLocation = LatLng(position.latitude, position.longitude);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:geolocator/geolocator.dart';
|
import 'package:geolocator/geolocator.dart';
|
||||||
import '../../services/api_service.dart';
|
import '../../services/api_service.dart';
|
||||||
|
|
@ -71,6 +72,15 @@ class _NeighborhoodPickerSheetState extends State<NeighborhoodPickerSheet> {
|
||||||
_isLoadingGps = true;
|
_isLoadingGps = true;
|
||||||
_gpsError = null;
|
_gpsError = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (kIsWeb) {
|
||||||
|
setState(() {
|
||||||
|
_gpsError = 'GPS detection unavailable on web. Enter your ZIP code below.';
|
||||||
|
_isLoadingGps = false;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final position = await Geolocator.getCurrentPosition(
|
final position = await Geolocator.getCurrentPosition(
|
||||||
desiredAccuracy: LocationAccuracy.low, // "fuzzy" — faster + less battery
|
desiredAccuracy: LocationAccuracy.low, // "fuzzy" — faster + less battery
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue