react-native-photo-video-editor 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +27 -0
- package/CONTRIBUTING.md +101 -0
- package/LICENSE +20 -0
- package/PhotoVideoEditor.podspec +23 -0
- package/README.md +53 -0
- package/android/build.gradle +61 -0
- package/android/src/main/AndroidManifest.xml +12 -0
- package/android/src/main/java/com/photovideoeditor/PhotoVideoEditorModule.kt +126 -0
- package/android/src/main/java/com/photovideoeditor/PhotoVideoEditorPackage.kt +31 -0
- package/android/src/main/java/com/photovideoeditor/editor/PhotoVideoEditorActivity.kt +2521 -0
- package/android/src/main/java/com/photovideoeditor/editor/README.md +5 -0
- package/android/src/main/java/com/photovideoeditor/files/SourceResolver.kt +29 -0
- package/android/src/main/java/com/photovideoeditor/media/BitmapSampling.kt +11 -0
- package/android/src/main/java/com/photovideoeditor/model/EditorRequest.kt +3 -0
- package/android/src/main/java/com/photovideoeditor/photo/export/PhotoExporter.kt +201 -0
- package/android/src/main/java/com/photovideoeditor/photo/render/OverlayGeometry.kt +22 -0
- package/android/src/main/java/com/photovideoeditor/photo/render/PhotoAdjustmentRenderer.kt +160 -0
- package/android/src/main/java/com/photovideoeditor/photo/render/PhotoAdjustments.kt +55 -0
- package/android/src/main/java/com/photovideoeditor/photo/render/PhotoEditSession.kt +121 -0
- package/android/src/main/java/com/photovideoeditor/photo/render/PhotoFilterPresets.kt +64 -0
- package/android/src/main/java/com/photovideoeditor/photo/render/PhotoLayer.kt +54 -0
- package/android/src/main/java/com/photovideoeditor/photo/render/PhotoLayerRenderer.kt +108 -0
- package/android/src/main/java/com/photovideoeditor/photo/render/PhotoLayerStack.kt +60 -0
- package/android/src/main/java/com/photovideoeditor/photo/render/PhotoTransformState.kt +52 -0
- package/android/src/main/java/com/photovideoeditor/photo/ui/CropOverlayView.kt +168 -0
- package/android/src/main/java/com/photovideoeditor/photo/ui/FilterThumbnailLoader.kt +57 -0
- package/android/src/main/java/com/photovideoeditor/photo/ui/LayerOverlayView.kt +202 -0
- package/android/src/main/java/com/photovideoeditor/photo/ui/PhotoPreviewRenderCoordinator.kt +57 -0
- package/android/src/main/java/com/photovideoeditor/photo/ui/ZoomableImageView.kt +96 -0
- package/android/src/main/java/com/photovideoeditor/stickers/OnlineStickerSheet.kt +310 -0
- package/android/src/main/java/com/photovideoeditor/stickers/OpenMojiCatalog.kt +172 -0
- package/android/src/main/java/com/photovideoeditor/stickers/RemoteImageLoader.kt +110 -0
- package/android/src/main/java/com/photovideoeditor/ui/DesignTokens.kt +93 -0
- package/android/src/main/java/com/photovideoeditor/ui/components/EditorComponents.kt +119 -0
- package/android/src/main/java/com/photovideoeditor/validation/EditorRequestValidator.kt +11 -0
- package/android/src/main/java/com/photovideoeditor/video/cache/TimelineThumbnailRepository.kt +130 -0
- package/android/src/main/java/com/photovideoeditor/video/export/VideoExporter.kt +319 -0
- package/android/src/main/java/com/photovideoeditor/video/render/VideoClip.kt +20 -0
- package/android/src/main/java/com/photovideoeditor/video/render/VideoEditSession.kt +136 -0
- package/android/src/main/java/com/photovideoeditor/video/render/VideoThumbnailGenerator.kt +31 -0
- package/android/src/main/java/com/photovideoeditor/video/render/VideoTransformState.kt +48 -0
- package/android/src/main/java/com/photovideoeditor/video/ui/TrimRangeView.kt +104 -0
- package/android/src/main/res/drawable/ic_add.xml +9 -0
- package/android/src/main/res/drawable/ic_arrow_back.xml +9 -0
- package/android/src/main/res/drawable/ic_aspect_ratio.xml +9 -0
- package/android/src/main/res/drawable/ic_auto_fix_high.xml +9 -0
- package/android/src/main/res/drawable/ic_check.xml +9 -0
- package/android/src/main/res/drawable/ic_chevron_left.xml +9 -0
- package/android/src/main/res/drawable/ic_chevron_right.xml +9 -0
- package/android/src/main/res/drawable/ic_close.xml +9 -0
- package/android/src/main/res/drawable/ic_compare.xml +9 -0
- package/android/src/main/res/drawable/ic_content_copy.xml +9 -0
- package/android/src/main/res/drawable/ic_content_cut.xml +9 -0
- package/android/src/main/res/drawable/ic_crop.xml +9 -0
- package/android/src/main/res/drawable/ic_crop_square.xml +9 -0
- package/android/src/main/res/drawable/ic_delete.xml +9 -0
- package/android/src/main/res/drawable/ic_drag_indicator.xml +9 -0
- package/android/src/main/res/drawable/ic_face.xml +9 -0
- package/android/src/main/res/drawable/ic_filter_center_focus.xml +9 -0
- package/android/src/main/res/drawable/ic_flare.xml +9 -0
- package/android/src/main/res/drawable/ic_format_size.xml +9 -0
- package/android/src/main/res/drawable/ic_fullscreen.xml +9 -0
- package/android/src/main/res/drawable/ic_layers.xml +9 -0
- package/android/src/main/res/drawable/ic_light_mode.xml +9 -0
- package/android/src/main/res/drawable/ic_lock.xml +9 -0
- package/android/src/main/res/drawable/ic_magic_button.xml +9 -0
- package/android/src/main/res/drawable/ic_movie.xml +9 -0
- package/android/src/main/res/drawable/ic_opacity.xml +9 -0
- package/android/src/main/res/drawable/ic_palette.xml +9 -0
- package/android/src/main/res/drawable/ic_photo_filter.xml +9 -0
- package/android/src/main/res/drawable/ic_redo.xml +9 -0
- package/android/src/main/res/drawable/ic_rotate_90_degrees_ccw.xml +9 -0
- package/android/src/main/res/drawable/ic_rotate_left.xml +9 -0
- package/android/src/main/res/drawable/ic_rotate_right.xml +9 -0
- package/android/src/main/res/drawable/ic_sentiment_satisfied.xml +9 -0
- package/android/src/main/res/drawable/ic_share.xml +9 -0
- package/android/src/main/res/drawable/ic_speed.xml +9 -0
- package/android/src/main/res/drawable/ic_straighten.xml +9 -0
- package/android/src/main/res/drawable/ic_title.xml +9 -0
- package/android/src/main/res/drawable/ic_tune.xml +9 -0
- package/android/src/main/res/drawable/ic_undo.xml +9 -0
- package/android/src/main/res/drawable/ic_visibility_off.xml +9 -0
- package/android/src/main/res/drawable/ic_wallpaper.xml +9 -0
- package/android/src/main/res/values/styles.xml +10 -0
- package/docs/api.md +9 -0
- package/docs/architecture.md +9 -0
- package/docs/customization.md +18 -0
- package/docs/development.md +21 -0
- package/docs/formats.md +27 -0
- package/docs/performance.md +3 -0
- package/docs/permissions.md +3 -0
- package/docs/photo-editor.md +41 -0
- package/docs/roadmap.md +12 -0
- package/docs/technology-decisions.md +17 -0
- package/docs/video-editor.md +76 -0
- package/ios/Editor/DesignTokens.swift +75 -0
- package/ios/Editor/PhotoVideoEditorOutcome.swift +9 -0
- package/ios/Editor/PhotoVideoEditorViewController.swift +2064 -0
- package/ios/Editor/README.md +5 -0
- package/ios/Editor/UI/EditorComponents.swift +138 -0
- package/ios/Files/SourceResolver.swift +13 -0
- package/ios/Models/EditorRequest.swift +3 -0
- package/ios/Photo/Export/PhotoExporter.swift +144 -0
- package/ios/Photo/Rendering/OverlayGeometry.swift +22 -0
- package/ios/Photo/Rendering/PhotoAdjustmentRenderer.swift +141 -0
- package/ios/Photo/Rendering/PhotoAdjustments.swift +57 -0
- package/ios/Photo/Rendering/PhotoEditSession.swift +121 -0
- package/ios/Photo/Rendering/PhotoFilterPresets.swift +45 -0
- package/ios/Photo/Rendering/PhotoLayer.swift +59 -0
- package/ios/Photo/Rendering/PhotoLayerRenderer.swift +94 -0
- package/ios/Photo/Rendering/PhotoLayerStack.swift +60 -0
- package/ios/Photo/Rendering/PhotoTransformState.swift +49 -0
- package/ios/Photo/Rendering/PixelBuffer.swift +68 -0
- package/ios/Photo/UI/CropOverlayView.swift +161 -0
- package/ios/Photo/UI/LayerOverlayView.swift +176 -0
- package/ios/Photo/UI/ZoomableImageView.swift +79 -0
- package/ios/PhotoVideoEditor.mm +20 -0
- package/ios/PhotoVideoEditor.swift +70 -0
- package/ios/Stickers/OnlineStickerSheetViewController.swift +418 -0
- package/ios/Stickers/OpenMojiCatalog.swift +188 -0
- package/ios/Stickers/RemoteImageLoader.swift +127 -0
- package/ios/Validation/EditorRequestValidator.swift +9 -0
- package/ios/Video/Cache/TimelineThumbnailRepository.swift +148 -0
- package/ios/Video/Export/VideoExporter.swift +275 -0
- package/ios/Video/Render/VideoClip.swift +21 -0
- package/ios/Video/Render/VideoEditSession.swift +99 -0
- package/ios/Video/Render/VideoThumbnailGenerator.swift +25 -0
- package/ios/Video/Render/VideoTransformState.swift +23 -0
- package/ios/Video/UI/TrimRangeView.swift +91 -0
- package/lib/module/NativePhotoVideoEditor.js +5 -0
- package/lib/module/NativePhotoVideoEditor.js.map +1 -0
- package/lib/module/errors.js +29 -0
- package/lib/module/errors.js.map +1 -0
- package/lib/module/events.js +27 -0
- package/lib/module/events.js.map +1 -0
- package/lib/module/index.js +91 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/project.js +4 -0
- package/lib/module/project.js.map +1 -0
- package/lib/module/types.js +2 -0
- package/lib/module/types.js.map +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/NativePhotoVideoEditor.d.ts +11 -0
- package/lib/typescript/src/NativePhotoVideoEditor.d.ts.map +1 -0
- package/lib/typescript/src/errors.d.ts +11 -0
- package/lib/typescript/src/errors.d.ts.map +1 -0
- package/lib/typescript/src/events.d.ts +5 -0
- package/lib/typescript/src/events.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +15 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/lib/typescript/src/project.d.ts +24 -0
- package/lib/typescript/src/project.d.ts.map +1 -0
- package/lib/typescript/src/types.d.ts +108 -0
- package/lib/typescript/src/types.d.ts.map +1 -0
- package/package.json +154 -0
- package/src/NativePhotoVideoEditor.ts +12 -0
- package/src/errors.ts +78 -0
- package/src/events.ts +43 -0
- package/src/index.ts +123 -0
- package/src/project.ts +17 -0
- package/src/types.ts +121 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
package com.photovideoeditor.photo.ui
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.graphics.Canvas
|
|
5
|
+
import android.graphics.Color
|
|
6
|
+
import android.graphics.DashPathEffect
|
|
7
|
+
import android.graphics.Paint
|
|
8
|
+
import android.graphics.RectF
|
|
9
|
+
import android.view.MotionEvent
|
|
10
|
+
import android.view.View
|
|
11
|
+
import android.text.TextPaint
|
|
12
|
+
import com.photovideoeditor.photo.render.PhotoLayer
|
|
13
|
+
import com.photovideoeditor.photo.render.LayerType
|
|
14
|
+
import kotlin.math.atan2
|
|
15
|
+
import kotlin.math.cos
|
|
16
|
+
import kotlin.math.hypot
|
|
17
|
+
import kotlin.math.min
|
|
18
|
+
import kotlin.math.sin
|
|
19
|
+
|
|
20
|
+
/** Reusable native move/scale/rotate surface shared by every overlay type. */
|
|
21
|
+
class LayerOverlayView(context: Context) : View(context) {
|
|
22
|
+
private enum class Gesture { NONE, DRAG, HANDLE, MULTI }
|
|
23
|
+
private var imageBounds = RectF()
|
|
24
|
+
var minScale = 0.2f
|
|
25
|
+
var maxScale = 8f
|
|
26
|
+
var layers: List<PhotoLayer> = emptyList()
|
|
27
|
+
set(value) { field = value; invalidate() }
|
|
28
|
+
var selectedLayerId: String? = null
|
|
29
|
+
set(value) { field = value; invalidate() }
|
|
30
|
+
var onLayerTapped: ((String?) -> Unit)? = null
|
|
31
|
+
var onLayerTransformChanged: ((id: String, x: Float, y: Float, scale: Float, rotationDegrees: Float) -> Unit)? = null
|
|
32
|
+
var onLayerTransformEnded: ((id: String) -> Unit)? = null
|
|
33
|
+
|
|
34
|
+
private var gesture = Gesture.NONE
|
|
35
|
+
private var gestureId: String? = null
|
|
36
|
+
private var startLayer: PhotoLayer? = null
|
|
37
|
+
private var startPointX = 0f
|
|
38
|
+
private var startPointY = 0f
|
|
39
|
+
private var startDistance = 1f
|
|
40
|
+
private var startAngle = 0f
|
|
41
|
+
private var startMidX = 0f
|
|
42
|
+
private var startMidY = 0f
|
|
43
|
+
private val density = resources.displayMetrics.density
|
|
44
|
+
private val handleRadius = 11f * density
|
|
45
|
+
private val handleTouchRadius = 30f * density
|
|
46
|
+
private val selectionPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
|
47
|
+
color = Color.WHITE; style = Paint.Style.STROKE; strokeWidth = 2f * density
|
|
48
|
+
pathEffect = DashPathEffect(floatArrayOf(8f * density, 5f * density), 0f)
|
|
49
|
+
}
|
|
50
|
+
private val handlePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.WHITE }
|
|
51
|
+
private val handleGlyphPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
|
52
|
+
color = Color.rgb(87, 55, 245); style = Paint.Style.STROKE; strokeWidth = 2f * density
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
fun setImageBounds(bounds: RectF) { imageBounds = RectF(bounds); invalidate() }
|
|
56
|
+
|
|
57
|
+
override fun onDraw(canvas: Canvas) {
|
|
58
|
+
super.onDraw(canvas)
|
|
59
|
+
val selected = selectedLayer() ?: return
|
|
60
|
+
val center = centerOf(selected)
|
|
61
|
+
val half = selectionHalfExtents(selected)
|
|
62
|
+
canvas.save()
|
|
63
|
+
canvas.translate(center.first, center.second)
|
|
64
|
+
canvas.rotate(selected.rotationDegrees)
|
|
65
|
+
canvas.drawRect(-half.first, -half.second, half.first, half.second, selectionPaint)
|
|
66
|
+
canvas.drawCircle(half.first, half.second, handleRadius, handlePaint)
|
|
67
|
+
val glyph = handleRadius * 0.52f
|
|
68
|
+
canvas.drawLine(half.first - glyph, half.second + glyph, half.first + glyph, half.second - glyph, handleGlyphPaint)
|
|
69
|
+
canvas.restore()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
override fun onTouchEvent(event: MotionEvent): Boolean {
|
|
73
|
+
if (imageBounds.width() <= 0 || imageBounds.height() <= 0) return false
|
|
74
|
+
when (event.actionMasked) {
|
|
75
|
+
MotionEvent.ACTION_DOWN -> {
|
|
76
|
+
val selected = selectedLayer()
|
|
77
|
+
val handleHit = selected?.takeIf { !it.locked && isHandleHit(it, event.x, event.y) }
|
|
78
|
+
val hit = handleHit ?: hitTest(event.x, event.y)
|
|
79
|
+
onLayerTapped?.invoke(hit?.id)
|
|
80
|
+
if (hit == null || hit.locked) { resetGesture(); return false }
|
|
81
|
+
gestureId = hit.id
|
|
82
|
+
startLayer = hit.copy()
|
|
83
|
+
startPointX = event.x; startPointY = event.y
|
|
84
|
+
if (handleHit != null) {
|
|
85
|
+
gesture = Gesture.HANDLE
|
|
86
|
+
val center = centerOf(hit)
|
|
87
|
+
startDistance = hypot(event.x - center.first, event.y - center.second).coerceAtLeast(1f)
|
|
88
|
+
startAngle = angle(center.first, center.second, event.x, event.y)
|
|
89
|
+
} else gesture = Gesture.DRAG
|
|
90
|
+
parent?.requestDisallowInterceptTouchEvent(true)
|
|
91
|
+
return true
|
|
92
|
+
}
|
|
93
|
+
MotionEvent.ACTION_POINTER_DOWN -> {
|
|
94
|
+
val layer = currentGestureLayer() ?: return false
|
|
95
|
+
if (event.pointerCount >= 2) {
|
|
96
|
+
startLayer = layer.copy()
|
|
97
|
+
val metrics = multiMetrics(event)
|
|
98
|
+
startDistance = metrics.distance.coerceAtLeast(1f); startAngle = metrics.angle
|
|
99
|
+
startMidX = metrics.midX; startMidY = metrics.midY
|
|
100
|
+
gesture = Gesture.MULTI
|
|
101
|
+
return true
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
MotionEvent.ACTION_MOVE -> {
|
|
105
|
+
val initial = startLayer ?: return false
|
|
106
|
+
when (gesture) {
|
|
107
|
+
Gesture.DRAG -> {
|
|
108
|
+
val x = (initial.x + (event.x - startPointX) / imageBounds.width()).coerceIn(0f, 1f)
|
|
109
|
+
val y = (initial.y + (event.y - startPointY) / imageBounds.height()).coerceIn(0f, 1f)
|
|
110
|
+
emit(initial, x, y, initial.scale, initial.rotationDegrees)
|
|
111
|
+
}
|
|
112
|
+
Gesture.HANDLE -> {
|
|
113
|
+
val center = centerOf(initial)
|
|
114
|
+
val scale = (initial.scale * hypot(event.x - center.first, event.y - center.second) / startDistance).coerceIn(minScale, maxScale)
|
|
115
|
+
val rotation = normalizeDegrees(initial.rotationDegrees + angleDelta(startAngle, angle(center.first, center.second, event.x, event.y)))
|
|
116
|
+
emit(initial, initial.x, initial.y, scale, rotation)
|
|
117
|
+
}
|
|
118
|
+
Gesture.MULTI -> if (event.pointerCount >= 2) {
|
|
119
|
+
val metrics = multiMetrics(event)
|
|
120
|
+
val scale = (initial.scale * metrics.distance / startDistance).coerceIn(minScale, maxScale)
|
|
121
|
+
val rotation = normalizeDegrees(initial.rotationDegrees + angleDelta(startAngle, metrics.angle))
|
|
122
|
+
val x = (initial.x + (metrics.midX - startMidX) / imageBounds.width()).coerceIn(0f, 1f)
|
|
123
|
+
val y = (initial.y + (metrics.midY - startMidY) / imageBounds.height()).coerceIn(0f, 1f)
|
|
124
|
+
emit(initial, x, y, scale, rotation)
|
|
125
|
+
}
|
|
126
|
+
Gesture.NONE -> return false
|
|
127
|
+
}
|
|
128
|
+
return true
|
|
129
|
+
}
|
|
130
|
+
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { finishGesture(); return true }
|
|
131
|
+
MotionEvent.ACTION_POINTER_UP -> return true
|
|
132
|
+
}
|
|
133
|
+
return false
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private fun emit(layer: PhotoLayer, x: Float, y: Float, scale: Float, rotation: Float) =
|
|
137
|
+
onLayerTransformChanged?.invoke(layer.id, x, y, scale, rotation)
|
|
138
|
+
|
|
139
|
+
private fun finishGesture() {
|
|
140
|
+
gestureId?.let { onLayerTransformEnded?.invoke(it) }
|
|
141
|
+
resetGesture()
|
|
142
|
+
parent?.requestDisallowInterceptTouchEvent(false)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private fun resetGesture() { gesture = Gesture.NONE; gestureId = null; startLayer = null }
|
|
146
|
+
private fun selectedLayer() = layers.firstOrNull { it.id == selectedLayerId && it.visible }
|
|
147
|
+
private fun currentGestureLayer() = layers.firstOrNull { it.id == gestureId && it.visible && !it.locked }
|
|
148
|
+
private fun centerOf(layer: PhotoLayer) = Pair(imageBounds.left + layer.x * imageBounds.width(), imageBounds.top + layer.y * imageBounds.height())
|
|
149
|
+
private fun selectionHalfExtents(layer: PhotoLayer): Pair<Float, Float> {
|
|
150
|
+
val shortSide = min(imageBounds.width(), imageBounds.height())
|
|
151
|
+
val base = when (layer.type) {
|
|
152
|
+
LayerType.TEXT -> {
|
|
153
|
+
val paint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
|
|
154
|
+
textSize = layer.fontSize / 48f * shortSide * 0.06f
|
|
155
|
+
layer.fontFamily?.let { typeface = android.graphics.Typeface.create(it, android.graphics.Typeface.NORMAL) }
|
|
156
|
+
}
|
|
157
|
+
val lines = layer.text.ifBlank { " " }.split("\n")
|
|
158
|
+
Pair((lines.maxOfOrNull { paint.measureText(it) } ?: paint.textSize) / 2f + 8f * density, paint.fontSpacing * lines.size / 2f + 6f * density)
|
|
159
|
+
}
|
|
160
|
+
LayerType.STICKER -> {
|
|
161
|
+
val size = shortSide * 0.18f
|
|
162
|
+
Pair(size / 2f, size / 2f)
|
|
163
|
+
}
|
|
164
|
+
LayerType.OVERLAY -> {
|
|
165
|
+
val aspect = layer.overlayAspectRatio ?: 1f
|
|
166
|
+
val baseSize = shortSide * 0.6f
|
|
167
|
+
if (aspect >= 1f) Pair(baseSize / 2f, (baseSize / aspect) / 2f) else Pair((baseSize * aspect) / 2f, baseSize / 2f)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return Pair((base.first * layer.scale).coerceAtLeast(handleRadius), (base.second * layer.scale).coerceAtLeast(handleRadius))
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private fun handlePoint(layer: PhotoLayer): Pair<Float, Float> {
|
|
174
|
+
val center = centerOf(layer); val half = selectionHalfExtents(layer)
|
|
175
|
+
val radians = Math.toRadians(layer.rotationDegrees.toDouble())
|
|
176
|
+
return Pair(center.first + (half.first * cos(radians) - half.second * sin(radians)).toFloat(), center.second + (half.first * sin(radians) + half.second * cos(radians)).toFloat())
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private fun isHandleHit(layer: PhotoLayer, x: Float, y: Float): Boolean {
|
|
180
|
+
val handle = handlePoint(layer)
|
|
181
|
+
return hypot(x - handle.first, y - handle.second) <= handleTouchRadius
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private fun hitTest(x: Float, y: Float): PhotoLayer? = layers.asReversed().firstOrNull { layer ->
|
|
185
|
+
if (!layer.visible) return@firstOrNull false
|
|
186
|
+
val center = centerOf(layer); val radians = Math.toRadians((-layer.rotationDegrees).toDouble())
|
|
187
|
+
val dx = x - center.first; val dy = y - center.second
|
|
188
|
+
val localX = dx * cos(radians).toFloat() - dy * sin(radians).toFloat()
|
|
189
|
+
val localY = dx * sin(radians).toFloat() + dy * cos(radians).toFloat()
|
|
190
|
+
val half = selectionHalfExtents(layer)
|
|
191
|
+
localX in -half.first..half.first && localY in -half.second..half.second
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private data class MultiMetrics(val midX: Float, val midY: Float, val distance: Float, val angle: Float)
|
|
195
|
+
private fun multiMetrics(event: MotionEvent): MultiMetrics {
|
|
196
|
+
val x0 = event.getX(0); val y0 = event.getY(0); val x1 = event.getX(1); val y1 = event.getY(1)
|
|
197
|
+
return MultiMetrics((x0 + x1) / 2f, (y0 + y1) / 2f, hypot(x1 - x0, y1 - y0), angle(x0, y0, x1, y1))
|
|
198
|
+
}
|
|
199
|
+
private fun angle(x0: Float, y0: Float, x1: Float, y1: Float) = Math.toDegrees(atan2(y1 - y0, x1 - x0).toDouble()).toFloat()
|
|
200
|
+
private fun angleDelta(from: Float, to: Float): Float = ((to - from + 540f) % 360f) - 180f
|
|
201
|
+
private fun normalizeDegrees(value: Float): Float = ((value % 360f) + 360f) % 360f
|
|
202
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
package com.photovideoeditor.photo.ui
|
|
2
|
+
|
|
3
|
+
import android.graphics.Bitmap
|
|
4
|
+
import android.os.Handler
|
|
5
|
+
import android.os.Looper
|
|
6
|
+
import java.util.concurrent.Executors
|
|
7
|
+
import java.util.concurrent.atomic.AtomicBoolean
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Runs the expensive photo pipeline away from the main thread and collapses a
|
|
11
|
+
* burst of slider/drag updates into the newest requested frame.
|
|
12
|
+
*/
|
|
13
|
+
class PhotoPreviewRenderCoordinator(
|
|
14
|
+
private val render: () -> Bitmap?,
|
|
15
|
+
private val display: (Bitmap?) -> Unit,
|
|
16
|
+
) {
|
|
17
|
+
private val executor = Executors.newSingleThreadExecutor { task ->
|
|
18
|
+
Thread(task, "photo-preview-renderer").apply { priority = Thread.NORM_PRIORITY - 1 }
|
|
19
|
+
}
|
|
20
|
+
private val mainHandler = Handler(Looper.getMainLooper())
|
|
21
|
+
private val rendering = AtomicBoolean(false)
|
|
22
|
+
private val dirty = AtomicBoolean(false)
|
|
23
|
+
private val closed = AtomicBoolean(false)
|
|
24
|
+
|
|
25
|
+
fun request() {
|
|
26
|
+
if (closed.get()) return
|
|
27
|
+
dirty.set(true)
|
|
28
|
+
startIfIdle()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
private fun startIfIdle() {
|
|
32
|
+
if (!rendering.compareAndSet(false, true)) return
|
|
33
|
+
executor.execute {
|
|
34
|
+
try {
|
|
35
|
+
do {
|
|
36
|
+
dirty.set(false)
|
|
37
|
+
val bitmap = if (closed.get()) null else render()
|
|
38
|
+
if (!closed.get()) mainHandler.post {
|
|
39
|
+
if (!closed.get()) display(bitmap)
|
|
40
|
+
}
|
|
41
|
+
} while (dirty.get() && !closed.get())
|
|
42
|
+
} finally {
|
|
43
|
+
rendering.set(false)
|
|
44
|
+
if (dirty.get() && !closed.get()) startIfIdle()
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Runs cleanup after any in-flight render, preventing bitmap recycle races. */
|
|
50
|
+
fun close(onRendererDrained: () -> Unit) {
|
|
51
|
+
if (!closed.compareAndSet(false, true)) return
|
|
52
|
+
dirty.set(false)
|
|
53
|
+
mainHandler.removeCallbacksAndMessages(null)
|
|
54
|
+
executor.execute(onRendererDrained)
|
|
55
|
+
executor.shutdown()
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
package com.photovideoeditor.photo.ui
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.graphics.Matrix
|
|
5
|
+
import android.graphics.RectF
|
|
6
|
+
import android.view.MotionEvent
|
|
7
|
+
import android.view.ScaleGestureDetector
|
|
8
|
+
import android.widget.ImageView
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* ImageView supporting pinch-to-zoom and single-finger pan for the photo
|
|
12
|
+
* preview, reporting the current on-screen image rect via [onBoundsChanged]
|
|
13
|
+
* so the crop overlay can stay aligned to it.
|
|
14
|
+
*/
|
|
15
|
+
class ZoomableImageView(context: Context) : ImageView(context) {
|
|
16
|
+
private val matrixValues = FloatArray(9)
|
|
17
|
+
private var minScale = 1f
|
|
18
|
+
private var maxScale = 4f
|
|
19
|
+
private var lastX = 0f
|
|
20
|
+
private var lastY = 0f
|
|
21
|
+
private var panning = false
|
|
22
|
+
var onBoundsChanged: ((RectF) -> Unit)? = null
|
|
23
|
+
var panZoomEnabled = true
|
|
24
|
+
|
|
25
|
+
private val scaleDetector = ScaleGestureDetector(
|
|
26
|
+
context,
|
|
27
|
+
object : ScaleGestureDetector.SimpleOnScaleGestureListener() {
|
|
28
|
+
override fun onScale(detector: ScaleGestureDetector): Boolean {
|
|
29
|
+
val targetScale = (currentScale() * detector.scaleFactor).coerceIn(minScale, maxScale)
|
|
30
|
+
val appliedFactor = targetScale / currentScale()
|
|
31
|
+
imageMatrix = Matrix(imageMatrix).apply { postScale(appliedFactor, appliedFactor, detector.focusX, detector.focusY) }
|
|
32
|
+
onBoundsChanged?.invoke(currentImageBounds())
|
|
33
|
+
return true
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
init {
|
|
39
|
+
scaleType = ScaleType.MATRIX
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Fits the current drawable to the view bounds and resets zoom/pan. */
|
|
43
|
+
fun resetToFit() {
|
|
44
|
+
val bitmapDrawable = drawable ?: return
|
|
45
|
+
val viewWidth = width.toFloat()
|
|
46
|
+
val viewHeight = height.toFloat()
|
|
47
|
+
val drawableWidth = bitmapDrawable.intrinsicWidth.toFloat()
|
|
48
|
+
val drawableHeight = bitmapDrawable.intrinsicHeight.toFloat()
|
|
49
|
+
if (viewWidth <= 0 || viewHeight <= 0 || drawableWidth <= 0 || drawableHeight <= 0) return
|
|
50
|
+
val scale = minOf(viewWidth / drawableWidth, viewHeight / drawableHeight)
|
|
51
|
+
val dx = (viewWidth - drawableWidth * scale) / 2f
|
|
52
|
+
val dy = (viewHeight - drawableHeight * scale) / 2f
|
|
53
|
+
imageMatrix = Matrix().apply {
|
|
54
|
+
postScale(scale, scale)
|
|
55
|
+
postTranslate(dx, dy)
|
|
56
|
+
}
|
|
57
|
+
minScale = scale
|
|
58
|
+
maxScale = scale * 4f
|
|
59
|
+
onBoundsChanged?.invoke(currentImageBounds())
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
fun currentImageBounds(): RectF {
|
|
63
|
+
val bitmapDrawable = drawable ?: return RectF(0f, 0f, width.toFloat(), height.toFloat())
|
|
64
|
+
val rect = RectF(0f, 0f, bitmapDrawable.intrinsicWidth.toFloat(), bitmapDrawable.intrinsicHeight.toFloat())
|
|
65
|
+
imageMatrix.mapRect(rect)
|
|
66
|
+
return rect
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private fun currentScale(): Float {
|
|
70
|
+
imageMatrix.getValues(matrixValues)
|
|
71
|
+
return matrixValues[Matrix.MSCALE_X]
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
override fun onTouchEvent(event: MotionEvent): Boolean {
|
|
75
|
+
if (!panZoomEnabled) return false
|
|
76
|
+
scaleDetector.onTouchEvent(event)
|
|
77
|
+
when (event.actionMasked) {
|
|
78
|
+
MotionEvent.ACTION_DOWN -> {
|
|
79
|
+
lastX = event.x
|
|
80
|
+
lastY = event.y
|
|
81
|
+
panning = true
|
|
82
|
+
}
|
|
83
|
+
MotionEvent.ACTION_POINTER_DOWN -> panning = false
|
|
84
|
+
MotionEvent.ACTION_MOVE -> if (panning && event.pointerCount == 1 && !scaleDetector.isInProgress) {
|
|
85
|
+
val dx = event.x - lastX
|
|
86
|
+
val dy = event.y - lastY
|
|
87
|
+
lastX = event.x
|
|
88
|
+
lastY = event.y
|
|
89
|
+
imageMatrix = Matrix(imageMatrix).apply { postTranslate(dx, dy) }
|
|
90
|
+
onBoundsChanged?.invoke(currentImageBounds())
|
|
91
|
+
}
|
|
92
|
+
MotionEvent.ACTION_UP, MotionEvent.ACTION_POINTER_UP -> panning = event.pointerCount <= 1
|
|
93
|
+
}
|
|
94
|
+
return true
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
package com.photovideoeditor.stickers
|
|
2
|
+
|
|
3
|
+
import android.app.Dialog
|
|
4
|
+
import android.content.Context
|
|
5
|
+
import android.graphics.Color
|
|
6
|
+
import android.os.Bundle
|
|
7
|
+
import android.os.Handler
|
|
8
|
+
import android.os.Looper
|
|
9
|
+
import android.text.Editable
|
|
10
|
+
import android.text.TextWatcher
|
|
11
|
+
import android.view.Gravity
|
|
12
|
+
import android.view.View
|
|
13
|
+
import android.view.ViewGroup
|
|
14
|
+
import android.view.Window
|
|
15
|
+
import android.view.WindowManager
|
|
16
|
+
import android.widget.EditText
|
|
17
|
+
import android.widget.FrameLayout
|
|
18
|
+
import android.widget.HorizontalScrollView
|
|
19
|
+
import android.widget.ImageView
|
|
20
|
+
import android.widget.LinearLayout
|
|
21
|
+
import android.widget.ProgressBar
|
|
22
|
+
import android.widget.TextView
|
|
23
|
+
import android.widget.Toast
|
|
24
|
+
import androidx.recyclerview.widget.GridLayoutManager
|
|
25
|
+
import androidx.recyclerview.widget.RecyclerView
|
|
26
|
+
import com.photovideoeditor.ui.DesignTokens
|
|
27
|
+
import com.photovideoeditor.ui.components.dpToPx
|
|
28
|
+
import com.photovideoeditor.ui.components.editorChip
|
|
29
|
+
import com.photovideoeditor.ui.components.editorPrimaryButton
|
|
30
|
+
import com.photovideoeditor.ui.components.roundedDrawable
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Bottom sheet listing OpenMoji's free sticker catalog (search + category filter + grid),
|
|
34
|
+
* built as a plain [Dialog] (no Material `BottomSheetDialog`, which isn't a dependency here) that
|
|
35
|
+
* slides up from the bottom and covers ~85% of the screen height.
|
|
36
|
+
*/
|
|
37
|
+
class OnlineStickerSheet(
|
|
38
|
+
context: Context,
|
|
39
|
+
private val onStickerPicked: (localFilePath: String) -> Unit
|
|
40
|
+
) : Dialog(context, android.R.style.Theme_Black_NoTitleBar) {
|
|
41
|
+
|
|
42
|
+
private val mainHandler = Handler(Looper.getMainLooper())
|
|
43
|
+
private var searchDebounce: Runnable? = null
|
|
44
|
+
|
|
45
|
+
private var allEntries: List<OpenMojiEntry> = emptyList()
|
|
46
|
+
private var selectedGroup: String? = null // null == "All"
|
|
47
|
+
private var searchQuery: String = ""
|
|
48
|
+
private var loadError: Exception? = null
|
|
49
|
+
private var loading = true
|
|
50
|
+
|
|
51
|
+
private lateinit var searchInput: EditText
|
|
52
|
+
private lateinit var chipRow: LinearLayout
|
|
53
|
+
private lateinit var recycler: RecyclerView
|
|
54
|
+
private lateinit var progress: ProgressBar
|
|
55
|
+
private lateinit var statusText: TextView
|
|
56
|
+
private lateinit var retryButton: View
|
|
57
|
+
private lateinit var adapter: StickerAdapter
|
|
58
|
+
|
|
59
|
+
override fun onCreate(savedInstanceState: Bundle?) {
|
|
60
|
+
super.onCreate(savedInstanceState)
|
|
61
|
+
setContentView(buildRootView())
|
|
62
|
+
configureWindow()
|
|
63
|
+
loadCatalog()
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private fun configureWindow() {
|
|
67
|
+
val window = this.window ?: return
|
|
68
|
+
window.setBackgroundDrawableResource(android.R.color.transparent)
|
|
69
|
+
window.setLayout(WindowManager.LayoutParams.MATCH_PARENT, WindowManager.LayoutParams.MATCH_PARENT)
|
|
70
|
+
window.setGravity(Gravity.BOTTOM)
|
|
71
|
+
window.setWindowAnimations(android.R.style.Animation_InputMethod)
|
|
72
|
+
setCanceledOnTouchOutside(true)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private fun dp(value: Int) = dpToPx(value, context)
|
|
76
|
+
|
|
77
|
+
private fun buildRootView(): View {
|
|
78
|
+
val screenHeight = context.resources.displayMetrics.heightPixels
|
|
79
|
+
val sheetHeight = (screenHeight * 0.85f).toInt()
|
|
80
|
+
|
|
81
|
+
val dimBackground = FrameLayout(context).apply {
|
|
82
|
+
setBackgroundColor(Color.argb(140, 0, 0, 0))
|
|
83
|
+
setOnClickListener { dismiss() }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
val sheet = LinearLayout(context).apply {
|
|
87
|
+
orientation = LinearLayout.VERTICAL
|
|
88
|
+
background = roundedTopDrawable()
|
|
89
|
+
isClickable = true
|
|
90
|
+
setPadding(dp(DesignTokens.spaceLg), dp(DesignTokens.spaceMd), dp(DesignTokens.spaceLg), dp(DesignTokens.spaceLg))
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Decorative drag handle.
|
|
94
|
+
sheet.addView(
|
|
95
|
+
View(context).apply { setBackgroundColor(DesignTokens.outlineVariant) },
|
|
96
|
+
LinearLayout.LayoutParams(dp(36), dp(4)).apply {
|
|
97
|
+
gravity = Gravity.CENTER_HORIZONTAL
|
|
98
|
+
bottomMargin = dp(DesignTokens.spaceMd)
|
|
99
|
+
}
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
sheet.addView(TextView(context).apply {
|
|
103
|
+
text = "Browse stickers"
|
|
104
|
+
setTextColor(DesignTokens.textPrimary)
|
|
105
|
+
textSize = 16f
|
|
106
|
+
setTypeface(typeface, android.graphics.Typeface.BOLD)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
searchInput = EditText(context).apply {
|
|
110
|
+
hint = "Search stickers"
|
|
111
|
+
setHintTextColor(DesignTokens.outline)
|
|
112
|
+
setTextColor(DesignTokens.onSurface)
|
|
113
|
+
background = roundedDrawable(DesignTokens.surfaceContainerHigh, DesignTokens.radiusLg, context)
|
|
114
|
+
setPadding(dp(DesignTokens.spaceMd), dp(DesignTokens.spaceSm), dp(DesignTokens.spaceMd), dp(DesignTokens.spaceSm))
|
|
115
|
+
setSingleLine(true)
|
|
116
|
+
addTextChangedListener(object : TextWatcher {
|
|
117
|
+
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
|
|
118
|
+
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
|
|
119
|
+
override fun afterTextChanged(s: Editable?) {
|
|
120
|
+
searchDebounce?.let { mainHandler.removeCallbacks(it) }
|
|
121
|
+
val runnable = Runnable {
|
|
122
|
+
searchQuery = s?.toString().orEmpty()
|
|
123
|
+
refreshList()
|
|
124
|
+
}
|
|
125
|
+
searchDebounce = runnable
|
|
126
|
+
mainHandler.postDelayed(runnable, 300)
|
|
127
|
+
}
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
sheet.addView(searchInput, LinearLayout.LayoutParams(MATCH, WRAP).apply {
|
|
131
|
+
topMargin = dp(DesignTokens.spaceMd)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
chipRow = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL }
|
|
135
|
+
sheet.addView(HorizontalScrollView(context).apply {
|
|
136
|
+
isHorizontalScrollBarEnabled = false
|
|
137
|
+
addView(chipRow)
|
|
138
|
+
}, LinearLayout.LayoutParams(MATCH, WRAP).apply { topMargin = dp(DesignTokens.spaceMd) })
|
|
139
|
+
|
|
140
|
+
val gridContainer = FrameLayout(context)
|
|
141
|
+
|
|
142
|
+
recycler = RecyclerView(context).apply {
|
|
143
|
+
layoutManager = GridLayoutManager(context, 4)
|
|
144
|
+
}
|
|
145
|
+
adapter = StickerAdapter()
|
|
146
|
+
recycler.adapter = adapter
|
|
147
|
+
gridContainer.addView(recycler, FrameLayout.LayoutParams(MATCH, MATCH))
|
|
148
|
+
|
|
149
|
+
progress = ProgressBar(context)
|
|
150
|
+
gridContainer.addView(progress, FrameLayout.LayoutParams(WRAP, WRAP, Gravity.CENTER))
|
|
151
|
+
|
|
152
|
+
val statusContainer = LinearLayout(context).apply {
|
|
153
|
+
orientation = LinearLayout.VERTICAL
|
|
154
|
+
gravity = Gravity.CENTER
|
|
155
|
+
}
|
|
156
|
+
statusText = TextView(context).apply {
|
|
157
|
+
setTextColor(DesignTokens.textSecondary)
|
|
158
|
+
textSize = 13f
|
|
159
|
+
gravity = Gravity.CENTER
|
|
160
|
+
}
|
|
161
|
+
statusContainer.addView(statusText, LinearLayout.LayoutParams(WRAP, WRAP))
|
|
162
|
+
retryButton = editorPrimaryButton(context, "Retry") { loadCatalog() }
|
|
163
|
+
statusContainer.addView(retryButton, LinearLayout.LayoutParams(WRAP, dp(DesignTokens.touchTargetMin)).apply {
|
|
164
|
+
topMargin = dp(DesignTokens.spaceMd)
|
|
165
|
+
})
|
|
166
|
+
gridContainer.addView(statusContainer, FrameLayout.LayoutParams(WRAP, WRAP, Gravity.CENTER))
|
|
167
|
+
|
|
168
|
+
sheet.addView(gridContainer, LinearLayout.LayoutParams(MATCH, 0, 1f).apply {
|
|
169
|
+
topMargin = dp(DesignTokens.spaceMd)
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
sheet.addView(TextView(context).apply {
|
|
173
|
+
text = "Stickers by OpenMoji (CC BY-SA 4.0)"
|
|
174
|
+
setTextColor(DesignTokens.textSubtle)
|
|
175
|
+
textSize = 10f
|
|
176
|
+
gravity = Gravity.CENTER
|
|
177
|
+
}, LinearLayout.LayoutParams(MATCH, WRAP).apply { topMargin = dp(DesignTokens.spaceSm) })
|
|
178
|
+
|
|
179
|
+
updateStatusViews()
|
|
180
|
+
|
|
181
|
+
val root = FrameLayout(context)
|
|
182
|
+
root.addView(dimBackground, FrameLayout.LayoutParams(MATCH, MATCH))
|
|
183
|
+
root.addView(sheet, FrameLayout.LayoutParams(MATCH, sheetHeight, Gravity.BOTTOM))
|
|
184
|
+
return root
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private fun roundedTopDrawable(): android.graphics.drawable.GradientDrawable =
|
|
188
|
+
android.graphics.drawable.GradientDrawable().apply {
|
|
189
|
+
setColor(DesignTokens.elevatedPanel)
|
|
190
|
+
cornerRadii = floatArrayOf(
|
|
191
|
+
dp(DesignTokens.radiusSheet).toFloat(), dp(DesignTokens.radiusSheet).toFloat(),
|
|
192
|
+
dp(DesignTokens.radiusSheet).toFloat(), dp(DesignTokens.radiusSheet).toFloat(),
|
|
193
|
+
0f, 0f, 0f, 0f
|
|
194
|
+
)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private fun loadCatalog() {
|
|
198
|
+
loading = true
|
|
199
|
+
loadError = null
|
|
200
|
+
updateStatusViews()
|
|
201
|
+
OpenMojiCatalog.load(context) { entries, error ->
|
|
202
|
+
loading = false
|
|
203
|
+
if (entries != null) {
|
|
204
|
+
allEntries = entries
|
|
205
|
+
loadError = null
|
|
206
|
+
rebuildChips()
|
|
207
|
+
refreshList()
|
|
208
|
+
} else {
|
|
209
|
+
loadError = error
|
|
210
|
+
}
|
|
211
|
+
updateStatusViews()
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
private fun rebuildChips() {
|
|
216
|
+
chipRow.removeAllViews()
|
|
217
|
+
val groups = listOf<String?>(null) + OpenMojiCatalog.groups()
|
|
218
|
+
groups.forEach { group ->
|
|
219
|
+
val label = group?.let { humanizeGroup(it) } ?: "All"
|
|
220
|
+
val chip = editorChip(context, label, selected = group == selectedGroup) {
|
|
221
|
+
selectedGroup = group
|
|
222
|
+
rebuildChips()
|
|
223
|
+
refreshList()
|
|
224
|
+
}
|
|
225
|
+
chipRow.addView(chip, LinearLayout.LayoutParams(WRAP, WRAP).apply { marginEnd = dp(DesignTokens.spaceSm) })
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private fun humanizeGroup(group: String): String =
|
|
230
|
+
group.split("-").joinToString(" ") { it.replaceFirstChar(Char::uppercase) }
|
|
231
|
+
|
|
232
|
+
private fun refreshList() {
|
|
233
|
+
val query = searchQuery.trim()
|
|
234
|
+
val list = if (query.isNotEmpty()) {
|
|
235
|
+
OpenMojiCatalog.search(query)
|
|
236
|
+
} else {
|
|
237
|
+
val group = selectedGroup
|
|
238
|
+
if (group == null) allEntries else OpenMojiCatalog.byGroup(group)
|
|
239
|
+
}
|
|
240
|
+
adapter.submit(list)
|
|
241
|
+
updateStatusViews()
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private fun updateStatusViews() {
|
|
245
|
+
progress.visibility = if (loading) View.VISIBLE else View.GONE
|
|
246
|
+
val error = loadError
|
|
247
|
+
val empty = !loading && error == null && adapter.itemCount == 0
|
|
248
|
+
recycler.visibility = if (!loading && error == null && !empty) View.VISIBLE else View.GONE
|
|
249
|
+
statusText.visibility = if (!loading && (error != null || empty)) View.VISIBLE else View.GONE
|
|
250
|
+
retryButton.visibility = if (!loading && error != null) View.VISIBLE else View.GONE
|
|
251
|
+
statusText.text = when {
|
|
252
|
+
error != null -> "Couldn't load stickers — check your connection."
|
|
253
|
+
empty -> "No stickers found."
|
|
254
|
+
else -> ""
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
private inner class StickerViewHolder(itemView: FrameLayout) : RecyclerView.ViewHolder(itemView) {
|
|
259
|
+
val imageView: ImageView = itemView.getChildAt(0) as ImageView
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
private inner class StickerAdapter : RecyclerView.Adapter<StickerViewHolder>() {
|
|
263
|
+
private var items: List<OpenMojiEntry> = emptyList()
|
|
264
|
+
|
|
265
|
+
fun submit(newItems: List<OpenMojiEntry>) {
|
|
266
|
+
items = newItems
|
|
267
|
+
notifyDataSetChanged()
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
override fun getItemCount(): Int = items.size
|
|
271
|
+
|
|
272
|
+
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): StickerViewHolder {
|
|
273
|
+
val cardSize = dp(72)
|
|
274
|
+
val image = ImageView(context).apply {
|
|
275
|
+
scaleType = ImageView.ScaleType.CENTER_INSIDE
|
|
276
|
+
}
|
|
277
|
+
val cell = FrameLayout(context).apply {
|
|
278
|
+
background = roundedDrawable(DesignTokens.surfaceContainer, DesignTokens.radiusMd, context)
|
|
279
|
+
addView(image, FrameLayout.LayoutParams(cardSize - dp(16), cardSize - dp(16), Gravity.CENTER))
|
|
280
|
+
}
|
|
281
|
+
cell.layoutParams = RecyclerView.LayoutParams(cardSize, cardSize).apply {
|
|
282
|
+
setMargins(dp(4), dp(4), dp(4), dp(4))
|
|
283
|
+
}
|
|
284
|
+
return StickerViewHolder(cell)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
override fun onBindViewHolder(holder: StickerViewHolder, position: Int) {
|
|
288
|
+
val entry = items[position]
|
|
289
|
+
RemoteImageLoader.loadThumbnail(context, OpenMojiCatalog.thumbnailUrl(entry.hexcode), holder.imageView)
|
|
290
|
+
holder.itemView.setOnClickListener { onStickerTapped(entry) }
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
private fun onStickerTapped(entry: OpenMojiEntry) {
|
|
295
|
+
Toast.makeText(context, "Downloading sticker…", Toast.LENGTH_SHORT).show()
|
|
296
|
+
RemoteImageLoader.downloadFull(context, OpenMojiCatalog.fullUrl(entry.hexcode)) { path, error ->
|
|
297
|
+
if (path != null) {
|
|
298
|
+
onStickerPicked(path)
|
|
299
|
+
dismiss()
|
|
300
|
+
} else {
|
|
301
|
+
Toast.makeText(context, "Couldn't download that sticker, try another.", Toast.LENGTH_SHORT).show()
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
companion object {
|
|
307
|
+
private const val MATCH = ViewGroup.LayoutParams.MATCH_PARENT
|
|
308
|
+
private const val WRAP = ViewGroup.LayoutParams.WRAP_CONTENT
|
|
309
|
+
}
|
|
310
|
+
}
|