add new dependencies
|
|
@ -1,128 +0,0 @@
|
|||
import 'package:adaptive_number/adaptive_number.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
// Since both implementations of Number only forward most method call,
|
||||
// tests are kept minimal and should mainly concentrate on verifying that no
|
||||
// wrong operator and/or method is being called because of a mix up
|
||||
void main() {
|
||||
group('utility functions', () {
|
||||
test('retrieve value as int', () {
|
||||
expect(Number.zero.intValue, 0);
|
||||
expect(Number.one.intValue, 1);
|
||||
expect(Number.two.intValue, 2);
|
||||
expect(Number(42).intValue, 42);
|
||||
});
|
||||
});
|
||||
|
||||
group('other overwrites', () {
|
||||
test('hash code', () {
|
||||
expect(Number.zero.hashCode, 0.hashCode);
|
||||
expect(Number.one.hashCode, 1.hashCode);
|
||||
expect(Number.two.hashCode, 2.hashCode);
|
||||
expect(Number(42).hashCode, 42);
|
||||
});
|
||||
|
||||
test('to string', () {
|
||||
expect(Number.one.toString(), '1');
|
||||
expect((-Number.one).toString(), '-1');
|
||||
});
|
||||
|
||||
test('toRadixString', () {
|
||||
expect(Number(42).toRadixString(2), '101010');
|
||||
});
|
||||
|
||||
test('abs', () {
|
||||
expect(Number(-42).abs(), Number(42));
|
||||
});
|
||||
|
||||
test('compare to', () {
|
||||
expect(Number.one.compareTo(Number.two), -1);
|
||||
expect(Number.two.compareTo(Number.one), 1);
|
||||
expect(Number.one.compareTo(Number.one), 0);
|
||||
});
|
||||
});
|
||||
|
||||
group('operator tests', () {
|
||||
test('add', () {
|
||||
expect(Number.one + Number.two, Number(3));
|
||||
});
|
||||
|
||||
test('subtract', () {
|
||||
expect(Number.two - Number.one, Number.one);
|
||||
});
|
||||
|
||||
test('invert', () {
|
||||
expect((-Number.one).intValue, -1);
|
||||
expect((-Number.zero).intValue, 0);
|
||||
});
|
||||
|
||||
test('multiply', () {
|
||||
expect(Number.two * Number(3), Number(6));
|
||||
});
|
||||
|
||||
test('& bitwise and', () {
|
||||
expect(Number(1234) & Number(9876), Number(1168));
|
||||
expect(Number(-1234) & Number(9876), Number(8708));
|
||||
});
|
||||
|
||||
test('>> bitwise shift right', () {
|
||||
expect(Number(23423) >> 8, Number(91));
|
||||
expect(Number(-87653) >> 12, Number(-22));
|
||||
});
|
||||
|
||||
test('<< bitwise shift left', () {
|
||||
expect(Number(23423) << 8, Number(5996288));
|
||||
expect(Number(-87653) << 12, Number(-359026688));
|
||||
});
|
||||
|
||||
test('^ bitwise xor', () {
|
||||
expect(Number(1234) ^ Number(9876), Number(8774));
|
||||
});
|
||||
|
||||
test('| bitwise or', () {
|
||||
expect(Number(1234) | Number(9876), Number(9942));
|
||||
expect(Number(-1234) | Number(9876), Number(-66));
|
||||
});
|
||||
|
||||
test('< less than', () {
|
||||
expect(Number.one < Number.two, isTrue);
|
||||
expect(Number.two < Number.one, isFalse);
|
||||
});
|
||||
|
||||
test('< less than or equal', () {
|
||||
expect(Number.two <= Number.one, isFalse);
|
||||
expect(Number.one <= Number.two, isTrue);
|
||||
expect(Number.two <= Number.two, isTrue);
|
||||
});
|
||||
|
||||
test('< greater than', () {
|
||||
expect(Number.two > Number.one, isTrue);
|
||||
expect(Number.one > Number.two, isFalse);
|
||||
});
|
||||
|
||||
test('< greater than or equal', () {
|
||||
expect(Number.two >= Number.one, isTrue);
|
||||
expect(Number.one >= Number.two, isFalse);
|
||||
expect(Number.two >= Number.two, isTrue);
|
||||
});
|
||||
|
||||
test('modulo', () {
|
||||
expect(Number.two % Number.one, Number.zero);
|
||||
expect(Number(3) % Number.one, Number.zero);
|
||||
expect(Number.one % Number.two, Number.one);
|
||||
});
|
||||
|
||||
test('truncating division', () {
|
||||
expect(Number(1000) ~/ Number(-3), Number(-333));
|
||||
});
|
||||
|
||||
test('equals', () {
|
||||
expect(Number(0) == Number.zero, isTrue);
|
||||
expect(Number(1) == Number.one, isTrue);
|
||||
expect(Number(2) == Number.two, isTrue);
|
||||
|
||||
expect(Number(1) == Number.zero, isFalse);
|
||||
expect(Number.one == Number.zero, isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
21
audio_waveforms/LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2022 Simform Solutions
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
8
audio_waveforms/android/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
*.iml
|
||||
.gradle
|
||||
/local.properties
|
||||
/.idea/workspace.xml
|
||||
/.idea/libraries
|
||||
.DS_Store
|
||||
/build
|
||||
/captures
|
||||
55
audio_waveforms/android/build.gradle
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
group 'com.simform.audio_waveforms'
|
||||
version '1.0-SNAPSHOT'
|
||||
|
||||
buildscript {
|
||||
ext.kotlin_version = '1.8.20'
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:8.1.0'
|
||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
apply plugin: 'com.android.library'
|
||||
apply plugin: 'kotlin-android'
|
||||
|
||||
android {
|
||||
if (project.android.hasProperty("namespace")) {
|
||||
namespace "com.simform.audio_waveforms"
|
||||
}
|
||||
|
||||
compileSdk 34
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = '1.8'
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
main.java.srcDirs += 'src/main/kotlin'
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
minSdkVersion 21
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("androidx.multidex:multidex:2.0.1")
|
||||
implementation "com.google.android.exoplayer:exoplayer:2.17.1"
|
||||
}
|
||||
3
audio_waveforms/android/gradle.properties
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
org.gradle.jvmargs=-Xmx1536M
|
||||
android.useAndroidX=true
|
||||
android.enableJetifier=true
|
||||
5
audio_waveforms/android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-all.zip
|
||||
1
audio_waveforms/android/settings.gradle
Normal file
|
|
@ -0,0 +1 @@
|
|||
rootProject.name = 'audio_waveforms'
|
||||
3
audio_waveforms/android/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="com.simform.audio_waveforms">
|
||||
</manifest>
|
||||
|
|
@ -0,0 +1,239 @@
|
|||
package com.simform.audio_waveforms
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import com.google.android.exoplayer2.ExoPlayer
|
||||
import com.google.android.exoplayer2.MediaItem
|
||||
import com.google.android.exoplayer2.PlaybackException
|
||||
import com.google.android.exoplayer2.Player
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
class AudioPlayer(
|
||||
context: Context,
|
||||
channel: MethodChannel,
|
||||
playerKey: String
|
||||
) {
|
||||
private var handler: Handler = Handler(Looper.getMainLooper())
|
||||
private var runnable: Runnable? = null
|
||||
private var methodChannel = channel
|
||||
private var appContext = context
|
||||
private var player: ExoPlayer? = null
|
||||
private var playerListener: Player.Listener? = null
|
||||
private var isPlayerPrepared: Boolean = false
|
||||
private var finishMode = FinishMode.Stop
|
||||
private var key = playerKey
|
||||
private var updateFrequency: Long = 200
|
||||
|
||||
fun preparePlayer(
|
||||
result: MethodChannel.Result,
|
||||
path: String?,
|
||||
volume: Float?,
|
||||
frequency: Long?,
|
||||
) {
|
||||
if (path != null) {
|
||||
frequency?.let {
|
||||
updateFrequency = it
|
||||
}
|
||||
val uri = Uri.parse(path)
|
||||
val mediaItem = MediaItem.fromUri(uri)
|
||||
stop()
|
||||
player?.clearMediaItems()
|
||||
player = ExoPlayer.Builder(appContext).build()
|
||||
player?.setMediaItem(mediaItem)
|
||||
player?.prepare()
|
||||
playerListener = object : Player.Listener {
|
||||
|
||||
override fun onPlayerError(error: PlaybackException) {
|
||||
super.onPlayerError(error)
|
||||
result.error(Constants.LOG_TAG, error.message, "Unable to load media source.")
|
||||
}
|
||||
|
||||
override fun onPlayerStateChanged(isReady: Boolean, state: Int) {
|
||||
if (!isPlayerPrepared) {
|
||||
if (state == Player.STATE_READY) {
|
||||
player?.volume = volume ?: 1F
|
||||
isPlayerPrepared = true
|
||||
result.success(true)
|
||||
}
|
||||
}
|
||||
if (state == Player.STATE_ENDED) {
|
||||
val args: MutableMap<String, Any?> = HashMap()
|
||||
when (finishMode) {
|
||||
FinishMode.Stop -> {
|
||||
player?.stop()
|
||||
player?.release()
|
||||
player = null
|
||||
stopListening()
|
||||
args[Constants.finishType] = 2
|
||||
}
|
||||
|
||||
FinishMode.Loop -> {
|
||||
player?.seekTo(0)
|
||||
player?.play()
|
||||
args[Constants.finishType] = 0
|
||||
}
|
||||
|
||||
FinishMode.Pause -> {
|
||||
player?.seekTo(0)
|
||||
player?.playWhenReady = false
|
||||
stopListening()
|
||||
args[Constants.finishType] = 1
|
||||
}
|
||||
}
|
||||
args[Constants.playerKey] = key
|
||||
methodChannel.invokeMethod(
|
||||
Constants.onDidFinishPlayingAudio,
|
||||
args
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
player?.addListener(playerListener!!)
|
||||
} else {
|
||||
result.error(Constants.LOG_TAG, "path to audio file or unique key can't be null", "")
|
||||
}
|
||||
}
|
||||
|
||||
fun seekToPosition(result: MethodChannel.Result, progress: Long?) {
|
||||
if (progress != null) {
|
||||
player?.seekTo(progress)
|
||||
sendCurrentDuration()
|
||||
result.success(true)
|
||||
} else {
|
||||
result.success(false)
|
||||
}
|
||||
}
|
||||
|
||||
fun start(result: MethodChannel.Result) {
|
||||
try {
|
||||
player?.playWhenReady = true
|
||||
player?.play()
|
||||
result.success(true)
|
||||
startListening(result)
|
||||
} catch (e: Exception) {
|
||||
result.error(Constants.LOG_TAG, "Can not start the player", e.toString())
|
||||
}
|
||||
}
|
||||
|
||||
fun getDuration(result: MethodChannel.Result, durationType: DurationType) {
|
||||
try {
|
||||
if (durationType == DurationType.Current) {
|
||||
val duration = player?.currentPosition
|
||||
result.success(duration)
|
||||
} else {
|
||||
val duration = player?.duration
|
||||
result.success(duration)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
result.error(Constants.LOG_TAG, "Can not get duration", e.toString())
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
stopListening()
|
||||
if (playerListener != null) {
|
||||
player?.removeListener(playerListener!!)
|
||||
}
|
||||
isPlayerPrepared = false
|
||||
player?.stop()
|
||||
}
|
||||
|
||||
|
||||
fun pause() {
|
||||
stopListening()
|
||||
player?.pause()
|
||||
}
|
||||
|
||||
fun release(result: MethodChannel.Result) {
|
||||
try {
|
||||
player?.release()
|
||||
result.success(true)
|
||||
} catch (e: Exception) {
|
||||
result.error(Constants.LOG_TAG, "Failed to release player resource", e.toString())
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fun setVolume(volume: Float?, result: MethodChannel.Result) {
|
||||
try {
|
||||
if (volume != null) {
|
||||
player?.volume = volume
|
||||
result.success(true)
|
||||
} else {
|
||||
result.success(false)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
result.success(false)
|
||||
}
|
||||
}
|
||||
|
||||
fun setRate(rate: Float?, result: MethodChannel.Result) {
|
||||
try {
|
||||
if (rate != null) {
|
||||
player?.setPlaybackSpeed(rate)
|
||||
result.success(true)
|
||||
} else {
|
||||
result.success(false)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
result.success(false)
|
||||
}
|
||||
}
|
||||
|
||||
fun setFinishMode(result: MethodChannel.Result, releaseModeType: Int?) {
|
||||
try {
|
||||
when (releaseModeType) {
|
||||
0 -> {
|
||||
this.finishMode = FinishMode.Loop
|
||||
}
|
||||
|
||||
1 -> {
|
||||
this.finishMode = FinishMode.Pause
|
||||
}
|
||||
|
||||
2 -> {
|
||||
this.finishMode = FinishMode.Stop
|
||||
}
|
||||
|
||||
null -> {
|
||||
throw Exception("Release mode is null")
|
||||
}
|
||||
|
||||
else -> {
|
||||
throw Exception("Invalid Finish mode")
|
||||
}
|
||||
}
|
||||
result.success(null)
|
||||
} catch (e: Exception) {
|
||||
result.error(Constants.LOG_TAG, "Can not set the release mode", e.toString())
|
||||
}
|
||||
}
|
||||
|
||||
private fun startListening(result: MethodChannel.Result) {
|
||||
runnable = object : Runnable {
|
||||
override fun run() {
|
||||
sendCurrentDuration()
|
||||
handler.postDelayed(this, updateFrequency)
|
||||
}
|
||||
}
|
||||
handler.post(runnable!!)
|
||||
|
||||
}
|
||||
|
||||
private fun stopListening() {
|
||||
runnable?.let { handler.removeCallbacks(it) }
|
||||
sendCurrentDuration()
|
||||
}
|
||||
|
||||
private fun sendCurrentDuration() {
|
||||
val currentPosition = player?.currentPosition ?: 0
|
||||
val args: MutableMap<String, Any?> = HashMap()
|
||||
args[Constants.current] = currentPosition
|
||||
args[Constants.playerKey] = key
|
||||
methodChannel.invokeMethod(Constants.onCurrentDuration, args)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,263 @@
|
|||
package com.simform.audio_waveforms
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.content.pm.PackageManager
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioRecord
|
||||
import android.media.MediaMetadataRetriever
|
||||
import android.media.MediaMetadataRetriever.METADATA_KEY_DURATION
|
||||
import android.media.MediaRecorder
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.app.ActivityCompat
|
||||
import com.simform.audio_waveforms.Constants.LOG_TAG
|
||||
import com.simform.audio_waveforms.encoders.*
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import io.flutter.plugin.common.MethodChannel.Result
|
||||
import io.flutter.plugin.common.PluginRegistry
|
||||
import java.io.File
|
||||
import kotlin.math.sqrt
|
||||
|
||||
class AudioRecorder : PluginRegistry.RequestPermissionsResultListener {
|
||||
private var permissions = arrayOf(Manifest.permission.RECORD_AUDIO)
|
||||
private var audioRecord: AudioRecord? = null
|
||||
private var channelConfig: Int = AudioFormat.CHANNEL_IN_MONO
|
||||
private var audioFormat: Int = AudioFormat.ENCODING_PCM_16BIT
|
||||
private var bufferSize: Int? = null
|
||||
private var recorderState: RecorderState = RecorderState.Disposed
|
||||
private var filePath: String? = null
|
||||
private var recordingThread: Thread? = null
|
||||
private var recorderSettings: RecorderSettings? = null
|
||||
private var encoder: Encoder? = null
|
||||
lateinit var channel: MethodChannel
|
||||
private var commonEncoder = CommonEncoder()
|
||||
private var wavEncoder: WavEncoder? = null
|
||||
private var successCallback: RequestPermissionsSuccessCallback? = null
|
||||
private var totalSamples = 0L
|
||||
private val channelCount: Int
|
||||
get() = when (channelConfig) {
|
||||
AudioFormat.CHANNEL_IN_MONO -> 1
|
||||
AudioFormat.CHANNEL_IN_STEREO -> 2
|
||||
else -> 1
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(
|
||||
requestCode: Int, permissions: Array<out String>, grantResults: IntArray
|
||||
): Boolean {
|
||||
return if (requestCode == Constants.RECORD_AUDIO_REQUEST_CODE) {
|
||||
successCallback?.onSuccess(grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED)
|
||||
grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun isPermissionGranted(activity: Activity?): Boolean {
|
||||
val result = ActivityCompat.checkSelfPermission(activity!!, permissions[0])
|
||||
return result == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
fun checkPermission(
|
||||
result: Result, activity: Activity?, successCallback: RequestPermissionsSuccessCallback
|
||||
) {
|
||||
this.successCallback = successCallback
|
||||
if (!isPermissionGranted(activity)) {
|
||||
activity?.let {
|
||||
ActivityCompat.requestPermissions(
|
||||
it, permissions, Constants.RECORD_AUDIO_REQUEST_CODE
|
||||
)
|
||||
}
|
||||
} else {
|
||||
result.success(true)
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
@SuppressLint("MissingPermission")
|
||||
fun initRecorder(
|
||||
recorderSettings: RecorderSettings, channel: MethodChannel, result: Result
|
||||
) {
|
||||
filePath = recorderSettings.path
|
||||
if (filePath == null) return
|
||||
this.channel = channel
|
||||
bufferSize =
|
||||
AudioRecord.getMinBufferSize(recorderSettings.sampleRate, channelConfig, audioFormat)
|
||||
|
||||
if (bufferSize == AudioRecord.ERROR || bufferSize == AudioRecord.ERROR_BAD_VALUE) {
|
||||
result.error(
|
||||
LOG_TAG,
|
||||
"Invalid buffer size: $bufferSize",
|
||||
null
|
||||
)
|
||||
}
|
||||
try {
|
||||
audioRecord = AudioRecord(
|
||||
MediaRecorder.AudioSource.MIC,
|
||||
recorderSettings.sampleRate,
|
||||
channelConfig,
|
||||
audioFormat,
|
||||
bufferSize!!
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
result.error(
|
||||
LOG_TAG,
|
||||
"Error initializing AudioRecord: ${e.message}",
|
||||
null
|
||||
)
|
||||
return
|
||||
}
|
||||
this.recorderSettings = recorderSettings
|
||||
encoder = recorderSettings.encoder
|
||||
recorderState = RecorderState.Initialised
|
||||
result.success(true)
|
||||
}
|
||||
|
||||
fun start(result: Result) {
|
||||
if (recorderSettings == null || bufferSize == null) {
|
||||
result.error(
|
||||
LOG_TAG,
|
||||
"recorder settings is null or bufferSize is null",
|
||||
"recorderSettings: $recorderSettings, bufferSize: $bufferSize"
|
||||
)
|
||||
return
|
||||
}
|
||||
audioRecord?.startRecording()
|
||||
recorderState = RecorderState.Recording
|
||||
if (encoder?.encodeForWav == true) {
|
||||
wavEncoder = WavEncoder(
|
||||
wavFile = File(recorderSettings!!.path!!),
|
||||
sampleRate = recorderSettings!!.sampleRate
|
||||
)
|
||||
wavEncoder?.start(result)
|
||||
} else {
|
||||
commonEncoder.initCodec(recorderSettings = recorderSettings!!, result = result) {
|
||||
recordingThread?.join()
|
||||
}
|
||||
}
|
||||
val buffer = ByteArray(bufferSize!!)
|
||||
recordingThread = Thread {
|
||||
while (recorderState == RecorderState.Recording || recorderState == RecorderState.Paused) {
|
||||
if (recorderState == RecorderState.Recording) {
|
||||
val read = audioRecord?.read(buffer, 0, buffer.size) ?: 0
|
||||
|
||||
if (read > 0) {
|
||||
val audioData = buffer.copyOf(read)
|
||||
if (encoder?.encodeForWav == true) {
|
||||
wavEncoder?.writePcmData(audioData)
|
||||
} else {
|
||||
commonEncoder.queueInputBuffer(audioData)
|
||||
}
|
||||
val rms = calculateRms(audioData, read)
|
||||
totalSamples += read / channelCount
|
||||
val durationSec =
|
||||
totalSamples.toDouble() / (recorderSettings?.sampleRate
|
||||
?: Constants.DEFAULT_SAMPLE_RATE)
|
||||
val milliSeconds = (durationSec * 1000).toLong()
|
||||
sendBytesToFlutter(audioData, rms, milliSeconds)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
recordingThread?.start()
|
||||
result.success(true)
|
||||
}
|
||||
|
||||
fun stop(result: Result) {
|
||||
try {
|
||||
audioRecord?.stop()
|
||||
totalSamples = 0L
|
||||
recorderState = RecorderState.Stopped
|
||||
if (encoder?.encodeForWav == true) {
|
||||
wavEncoder?.stop(result)
|
||||
recordingThread?.join()
|
||||
sendRecordingResult(result)
|
||||
} else {
|
||||
commonEncoder.setOnEncodingCompleted {
|
||||
sendRecordingResult(result)
|
||||
}
|
||||
commonEncoder.signalToStop()
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
result.error(LOG_TAG, e.message, "An error occurred while stopping the recorder")
|
||||
return
|
||||
}
|
||||
release()
|
||||
}
|
||||
|
||||
private fun sendRecordingResult(result: Result) {
|
||||
val duration = getDuration(recorderSettings?.path)
|
||||
val hashMap = HashMap<String, Any?>()
|
||||
hashMap[Constants.resultFilePath] = recorderSettings?.path
|
||||
hashMap[Constants.resultDuration] = duration
|
||||
result.success(hashMap)
|
||||
}
|
||||
|
||||
private fun sendBytesToFlutter(chunk: ByteArray, rms: Double, milliSeconds: Long) {
|
||||
val args: MutableMap<String, Any?> = HashMap()
|
||||
args[Constants.normalisedRms] = rms
|
||||
args[Constants.bytes] = chunk
|
||||
args[Constants.recordedDuration] = milliSeconds
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
channel.invokeMethod(Constants.onAudioChunk, args)
|
||||
}
|
||||
}
|
||||
|
||||
private fun calculateRms(chunk: ByteArray, size: Int): Double {
|
||||
var sum = 0.0
|
||||
var count = 0
|
||||
|
||||
val adjustedSize = if (size % 2 == 0) size else size - 1
|
||||
for (i in 0 until adjustedSize step 2) {
|
||||
val low = chunk[i].toInt() and 0xff
|
||||
val high = chunk[i + 1].toInt()
|
||||
val sample = (high shl 8) or low
|
||||
|
||||
sum += sample * sample.toDouble()
|
||||
count++
|
||||
}
|
||||
|
||||
val normalisedRms = sqrt(sum / count) / 32767.0
|
||||
return normalisedRms
|
||||
}
|
||||
|
||||
fun pause(result: Result) {
|
||||
recorderState = RecorderState.Paused
|
||||
result.success(false)
|
||||
}
|
||||
|
||||
fun resume(result: Result) {
|
||||
recorderState = RecorderState.Recording
|
||||
result.success(true)
|
||||
}
|
||||
|
||||
fun release() {
|
||||
try {
|
||||
audioRecord?.release()
|
||||
} catch (e: Exception) {
|
||||
Log.e(LOG_TAG, "Error releasing AudioRecord: ${e.message}")
|
||||
}
|
||||
|
||||
audioRecord = null
|
||||
recorderState = RecorderState.Disposed
|
||||
}
|
||||
|
||||
private fun getDuration(path: String?): Int {
|
||||
val mediaMetadataRetriever = MediaMetadataRetriever()
|
||||
try {
|
||||
mediaMetadataRetriever.setDataSource(path)
|
||||
val duration = mediaMetadataRetriever.extractMetadata(METADATA_KEY_DURATION)
|
||||
return duration?.toInt() ?: -1
|
||||
} catch (e: Exception) {
|
||||
Log.e(LOG_TAG, "Error getting duration: ${e.message}")
|
||||
} finally {
|
||||
mediaMetadataRetriever.release()
|
||||
}
|
||||
return -1
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,338 @@
|
|||
package com.simform.audio_waveforms
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.media.MediaRecorder
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.annotation.RequiresApi
|
||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||
import io.flutter.embedding.engine.plugins.activity.ActivityAware
|
||||
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
|
||||
import io.flutter.plugin.common.MethodChannel.Result
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
|
||||
/** AudioWaveformsPlugin */
|
||||
class AudioWaveformsPlugin : FlutterPlugin, MethodCallHandler, ActivityAware {
|
||||
private lateinit var channel: MethodChannel
|
||||
private var recorder: MediaRecorder? = null
|
||||
private var activity: Activity? = null
|
||||
private lateinit var audioRecorder: AudioRecorder
|
||||
private var recorderSettings = RecorderSettings(path = null)
|
||||
private lateinit var applicationContext: Context
|
||||
private var audioPlayers = mutableMapOf<String, AudioPlayer?>()
|
||||
private var extractors = mutableMapOf<String, WaveformExtractor?>()
|
||||
private var pluginBinding: ActivityPluginBinding? = null
|
||||
private var record: AudioRecorder = AudioRecorder()
|
||||
|
||||
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
|
||||
channel = MethodChannel(flutterPluginBinding.binaryMessenger, Constants.methodChannelName)
|
||||
channel.setMethodCallHandler(this)
|
||||
audioRecorder = AudioRecorder()
|
||||
applicationContext = flutterPluginBinding.applicationContext
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.N)
|
||||
override fun onMethodCall(call: MethodCall, result: Result) {
|
||||
when (call.method) {
|
||||
Constants.initRecorder -> {
|
||||
val arguments = call.arguments
|
||||
if (arguments is Map<*, *>) {
|
||||
recorderSettings = RecorderSettings.fromJson(arguments)
|
||||
checkPathAndInitialiseRecorder(result, recorderSettings)
|
||||
} else {
|
||||
result.error(
|
||||
Constants.LOG_TAG, "Failed to initialise Recorder", "Invalid Arguments"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Constants.startRecording -> record.start(result)
|
||||
|
||||
Constants.stopRecording -> {
|
||||
record.stop(result)
|
||||
recorder = null
|
||||
}
|
||||
|
||||
Constants.pauseRecording -> record.pause(result)
|
||||
|
||||
Constants.resumeRecording -> record.resume(result)
|
||||
Constants.checkPermission -> audioRecorder.checkPermission(
|
||||
result, activity, result::success
|
||||
)
|
||||
|
||||
Constants.preparePlayer -> {
|
||||
val audioPath = call.argument(Constants.path) as String?
|
||||
val volume = call.argument(Constants.volume) as Double?
|
||||
val key = call.argument(Constants.playerKey) as String?
|
||||
val frequency = call.argument(Constants.updateFrequency) as Int?
|
||||
if (key != null) {
|
||||
initPlayer(key)
|
||||
audioPlayers[key]?.preparePlayer(
|
||||
result,
|
||||
audioPath,
|
||||
volume?.toFloat(),
|
||||
frequency?.toLong(),
|
||||
)
|
||||
} else {
|
||||
result.error(Constants.LOG_TAG, "Player key can't be null", "")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Constants.startPlayer -> {
|
||||
val key = call.argument(Constants.playerKey) as String?
|
||||
if (key != null) {
|
||||
audioPlayers[key]?.start(result)
|
||||
} else {
|
||||
result.error(Constants.LOG_TAG, "Player key can't be null", "")
|
||||
}
|
||||
}
|
||||
|
||||
Constants.stopPlayer -> {
|
||||
val key = call.argument(Constants.playerKey) as String?
|
||||
if (key != null) {
|
||||
try {
|
||||
audioPlayers[key]?.stop()
|
||||
result.success(true)
|
||||
} catch (e: Exception) {
|
||||
result.error(Constants.LOG_TAG, "Failed to stop player", e.message)
|
||||
}
|
||||
} else {
|
||||
result.error(Constants.LOG_TAG, "Player key can't be null", "")
|
||||
}
|
||||
}
|
||||
|
||||
Constants.pausePlayer -> {
|
||||
val key = call.argument(Constants.playerKey) as String?
|
||||
if (key != null) {
|
||||
try {
|
||||
audioPlayers[key]?.pause()
|
||||
result.success(true)
|
||||
} catch (e: Exception) {
|
||||
result.error(Constants.LOG_TAG, "Failed to pause player", e.message)
|
||||
}
|
||||
} else {
|
||||
result.error(Constants.LOG_TAG, "Player key can't be null", "")
|
||||
}
|
||||
}
|
||||
|
||||
Constants.releasePlayer -> {
|
||||
val key = call.argument(Constants.playerKey) as String?
|
||||
audioPlayers[key]?.release(result)
|
||||
}
|
||||
|
||||
Constants.seekTo -> {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val progress = call.argument(Constants.progress) as Int?
|
||||
val key = call.argument(Constants.playerKey) as String?
|
||||
if (key != null) {
|
||||
audioPlayers[key]?.seekToPosition(result, progress?.toLong())
|
||||
} else {
|
||||
result.error(Constants.LOG_TAG, "Player key can't be null", "")
|
||||
}
|
||||
} else {
|
||||
Log.e(
|
||||
Constants.LOG_TAG,
|
||||
"Minimum android O is required for seekTo function to works"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Constants.setVolume -> {
|
||||
val volume = call.argument(Constants.volume) as Double?
|
||||
val key = call.argument(Constants.playerKey) as String?
|
||||
if (key != null) {
|
||||
audioPlayers[key]?.setVolume(volume?.toFloat(), result)
|
||||
} else {
|
||||
result.error(Constants.LOG_TAG, "Player key can't be null", "")
|
||||
}
|
||||
}
|
||||
|
||||
Constants.setRate -> {
|
||||
val rate = call.argument(Constants.rate) as Double?
|
||||
val key = call.argument(Constants.playerKey) as String?
|
||||
if (key != null) {
|
||||
audioPlayers[key]?.setRate(rate?.toFloat(), result)
|
||||
} else {
|
||||
result.error(Constants.LOG_TAG, "Player key can't be null", "")
|
||||
}
|
||||
}
|
||||
|
||||
Constants.getDuration -> {
|
||||
val type =
|
||||
if ((call.argument(Constants.durationType) as Int?) == 0) DurationType.Current else DurationType.Max
|
||||
val key = call.argument(Constants.playerKey) as String?
|
||||
if (key != null) {
|
||||
audioPlayers[key]?.getDuration(result, type)
|
||||
} else {
|
||||
result.error(Constants.LOG_TAG, "Player key can't be null", "")
|
||||
}
|
||||
}
|
||||
|
||||
Constants.extractWaveformData -> {
|
||||
val key = call.argument(Constants.playerKey) as String?
|
||||
val path = call.argument(Constants.path) as String?
|
||||
val noOfSample = call.argument(Constants.noOfSamples) as Int?
|
||||
if (key != null) {
|
||||
createOrUpdateExtractor(
|
||||
playerKey = key,
|
||||
result = result,
|
||||
path = path,
|
||||
noOfSamples = noOfSample ?: 100,
|
||||
)
|
||||
} else {
|
||||
result.error(Constants.LOG_TAG, "Waveform key can't be null", "")
|
||||
}
|
||||
}
|
||||
|
||||
Constants.STOP_EXTRACTION -> {
|
||||
val key = call.argument(Constants.playerKey) as String?
|
||||
key?.let {
|
||||
extractors[it]?.stop()
|
||||
result.success(true)
|
||||
} ?: result.error(Constants.LOG_TAG, "Waveform key can't be null", "")
|
||||
}
|
||||
|
||||
Constants.stopAllPlayers -> {
|
||||
stopAllPlayer(result)
|
||||
}
|
||||
|
||||
Constants.finishMode -> {
|
||||
val releaseType = call.argument<Int?>(Constants.finishType)
|
||||
val key = call.argument<String?>(Constants.playerKey)
|
||||
key?.let {
|
||||
audioPlayers[it]?.setFinishMode(result, releaseType)
|
||||
}
|
||||
}
|
||||
|
||||
Constants.pauseAllPlayers -> {
|
||||
pauseAllPlayer(result)
|
||||
}
|
||||
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
private fun checkPathAndInitialiseRecorder(
|
||||
result: Result, recorderSettings: RecorderSettings
|
||||
) {
|
||||
if (recorderSettings.path == null) {
|
||||
val outputDir = activity?.cacheDir
|
||||
val outputFile: File?
|
||||
val dateTimeInstance = SimpleDateFormat(Constants.fileNameFormat, Locale.US)
|
||||
val currentDate = dateTimeInstance.format(Date())
|
||||
try {
|
||||
outputFile = File.createTempFile(currentDate, ".m4a", outputDir)
|
||||
recorderSettings.path = outputFile.path
|
||||
} catch (e: IOException) {
|
||||
result.error(Constants.LOG_TAG, "Failed to create file", e.message)
|
||||
return
|
||||
}
|
||||
}
|
||||
record.initRecorder(recorderSettings, channel, result)
|
||||
}
|
||||
|
||||
private fun initPlayer(playerKey: String) {
|
||||
if (!audioPlayers.containsKey(playerKey)) {
|
||||
val newPlayer = AudioPlayer(
|
||||
context = applicationContext,
|
||||
channel = channel,
|
||||
playerKey = playerKey,
|
||||
)
|
||||
audioPlayers[playerKey] = newPlayer
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
private fun createOrUpdateExtractor(
|
||||
playerKey: String,
|
||||
noOfSamples: Int,
|
||||
path: String?,
|
||||
result: Result,
|
||||
) {
|
||||
if (path == null) {
|
||||
result.error(Constants.LOG_TAG, "Path can't be null", "")
|
||||
return
|
||||
}
|
||||
extractors[playerKey]?.stop()
|
||||
extractors[playerKey] = WaveformExtractor(
|
||||
context = applicationContext,
|
||||
methodChannel = channel,
|
||||
expectedPoints = noOfSamples,
|
||||
key = playerKey,
|
||||
path = path,
|
||||
result = result,
|
||||
extractorCallBack = object : ExtractorCallBack {
|
||||
override fun onProgress(value: Float) {
|
||||
if (value == 1.0F) {
|
||||
result.success(extractors[playerKey]?.sampleData)
|
||||
}
|
||||
}
|
||||
|
||||
})
|
||||
extractors[playerKey]?.startDecode()
|
||||
}
|
||||
|
||||
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
channel.setMethodCallHandler(null)
|
||||
}
|
||||
|
||||
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
||||
activity = binding.activity
|
||||
pluginBinding = binding
|
||||
pluginBinding!!.addRequestPermissionsResultListener(this.audioRecorder)
|
||||
|
||||
}
|
||||
|
||||
override fun onDetachedFromActivityForConfigChanges() {
|
||||
activity = null
|
||||
}
|
||||
|
||||
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
|
||||
activity = binding.activity
|
||||
}
|
||||
|
||||
override fun onDetachedFromActivity() {
|
||||
recorder?.release()
|
||||
recorder = null
|
||||
audioPlayers.clear()
|
||||
extractors.clear()
|
||||
activity = null
|
||||
if (pluginBinding != null) {
|
||||
pluginBinding!!.removeRequestPermissionsResultListener(this.audioRecorder)
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopAllPlayer(result: Result) {
|
||||
try {
|
||||
for ((key, _) in audioPlayers) {
|
||||
audioPlayers[key]?.stop()
|
||||
audioPlayers[key] = null
|
||||
}
|
||||
result.success(true)
|
||||
} catch (e: Exception) {
|
||||
result.error(Constants.LOG_TAG, "Failed to stop players", e.message)
|
||||
}
|
||||
}
|
||||
|
||||
private fun pauseAllPlayer(result: Result) {
|
||||
try {
|
||||
for ((key, _) in audioPlayers) {
|
||||
audioPlayers[key]?.pause()
|
||||
}
|
||||
result.success(true)
|
||||
} catch (e: Exception) {
|
||||
result.error(Constants.LOG_TAG, "Failed to pause players", e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
package com.simform.audio_waveforms
|
||||
|
||||
/**
|
||||
* Configuration class for audio recording settings
|
||||
*
|
||||
* This data class encapsulates all the settings required for configuring
|
||||
* the audio recorder, including:
|
||||
* - Output file path
|
||||
* - Audio encoder/format
|
||||
* - Sample rate
|
||||
* - Bit rate
|
||||
*
|
||||
* It provides sensible defaults for common recording scenarios and includes
|
||||
* a factory method to create instances from JSON/Map data received from Flutter.
|
||||
*/
|
||||
data class RecorderSettings(
|
||||
/**
|
||||
* Path where the recorded audio file will be saved
|
||||
* Can be null if not specified by the caller
|
||||
*/
|
||||
var path: String?,
|
||||
|
||||
/**
|
||||
* Audio encoder to use for the recording
|
||||
* Defaults to AAC Low Complexity (AAC_LC)
|
||||
*/
|
||||
val encoder: Encoder = Encoder.AAC_LC,
|
||||
|
||||
/**
|
||||
* Sample rate in Hz (samples per second)
|
||||
* Defaults to 44100Hz (CD quality)
|
||||
*/
|
||||
val sampleRate: Int = 44100,
|
||||
|
||||
/**
|
||||
* Bit rate in bits per second
|
||||
* Defaults to 128kbps (good quality for most audio)
|
||||
*/
|
||||
val bitRate: Int = 128000
|
||||
) {
|
||||
companion object {
|
||||
/**
|
||||
* Creates a RecorderSettings instance from a Map/JSON object
|
||||
*
|
||||
* This factory method is used to convert parameters received from Flutter
|
||||
* into a RecorderSettings object. It handles default values when parameters
|
||||
* are missing.
|
||||
*
|
||||
* @param json The map containing recorder settings from Flutter
|
||||
* @return A configured RecorderSettings instance
|
||||
*/
|
||||
fun fromJson(json: Map<*, *>): RecorderSettings {
|
||||
return RecorderSettings(
|
||||
path = json[Constants.path] as String?,
|
||||
encoder = Encoder.fromString(json[Constants.encoder] as String?),
|
||||
sampleRate = (json[Constants.sampleRate] as Int?) ?: 44100,
|
||||
bitRate = json[Constants.bitRate] as Int
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,329 @@
|
|||
package com.simform.audio_waveforms
|
||||
|
||||
import android.media.MediaCodecInfo
|
||||
import android.media.MediaFormat
|
||||
import android.media.MediaMuxer
|
||||
import android.media.MediaRecorder
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import com.simform.audio_waveforms.Constants.LOG_TAG
|
||||
|
||||
/**
|
||||
* Specifies which duration value to retrieve from an audio player
|
||||
*/
|
||||
enum class DurationType {
|
||||
/** Current playback position */
|
||||
Current,
|
||||
/** Total duration of the audio file */
|
||||
Max
|
||||
}
|
||||
|
||||
/**
|
||||
* Global constants used throughout the audio_waveforms plugin
|
||||
*
|
||||
* This object contains method channel names, parameter keys, default values,
|
||||
* and other constants needed for the plugin's operation.
|
||||
*/
|
||||
object Constants {
|
||||
|
||||
// TODO: Update all const to uppercase
|
||||
const val initRecorder = "initRecorder"
|
||||
const val startRecording = "startRecording"
|
||||
const val stopRecording = "stopRecording"
|
||||
const val pauseRecording = "pauseRecording"
|
||||
const val resumeRecording = "resumeRecording"
|
||||
const val checkPermission = "checkPermission"
|
||||
const val path = "path"
|
||||
const val LOG_TAG = "AudioWaveforms"
|
||||
const val methodChannelName = "simform_audio_waveforms_plugin/methods"
|
||||
const val encoder = "encoder"
|
||||
const val sampleRate = "sampleRate"
|
||||
const val bitRate = "bitRate"
|
||||
const val fileNameFormat = "dd-MM-yy-hh-mm-ss"
|
||||
|
||||
const val preparePlayer = "preparePlayer"
|
||||
const val startPlayer = "startPlayer"
|
||||
const val stopPlayer = "stopPlayer"
|
||||
const val pausePlayer = "pausePlayer"
|
||||
const val releasePlayer = "releasePlayer"
|
||||
const val seekTo = "seekTo"
|
||||
const val progress = "progress"
|
||||
const val setVolume = "setVolume"
|
||||
const val finishMode = "finishMode"
|
||||
const val finishType = "finishType"
|
||||
const val volume = "volume"
|
||||
const val setRate = "setRate"
|
||||
const val rate = "rate"
|
||||
const val getDuration = "getDuration"
|
||||
const val durationType = "durationType"
|
||||
const val playerKey = "playerKey"
|
||||
const val current = "current"
|
||||
const val onCurrentDuration = "onCurrentDuration"
|
||||
const val stopAllPlayers = "stopAllPlayers"
|
||||
const val onDidFinishPlayingAudio = "onDidFinishPlayingAudio"
|
||||
const val extractWaveformData = "extractWaveformData"
|
||||
const val noOfSamples = "noOfSamples"
|
||||
const val onCurrentExtractedWaveformData = "onCurrentExtractedWaveformData"
|
||||
const val waveformData = "waveformData"
|
||||
const val updateFrequency = "updateFrequency"
|
||||
const val STOP_EXTRACTION = "stopExtraction"
|
||||
|
||||
const val resultFilePath = "resultFilePath"
|
||||
const val resultDuration = "resultDuration"
|
||||
const val pauseAllPlayers = "pauseAllPlayers"
|
||||
const val normalisedRms = "normalisedRms"
|
||||
const val bytes = "bytes"
|
||||
const val recordedDuration = "recordedDuration"
|
||||
const val onAudioChunk = "onAudioChunk"
|
||||
|
||||
// TODO: make user can set this in future
|
||||
const val CHANNEL: Int = 1
|
||||
const val BIT_PER_SAMPLE: Int = 16
|
||||
|
||||
const val RECORD_AUDIO_REQUEST_CODE = 1001
|
||||
|
||||
/// Indicates 128 bits in a single channel for 8-bit PCM
|
||||
const val EIGHT_BITS = 128f
|
||||
|
||||
/// Indicates 32767 bits in a single channel for 16-bit PCM
|
||||
const val SIXTEEN_BITS = 32767f
|
||||
|
||||
/// Indicates 2147483648f bits in a single channel for 32-bit PCM
|
||||
const val THIRTY_TWO_BITS = 2.14748365E9f
|
||||
const val ENCODER_THREAD = "EncoderThread"
|
||||
const val AAC_FILE_EXTENSION = "aac"
|
||||
const val DEFAULT_SAMPLE_RATE = 44100
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines behavior when audio playback reaches the end
|
||||
*
|
||||
* Controls what happens when an audio file finishes playing.
|
||||
*
|
||||
* @property value The integer value sent to the platform channel
|
||||
*/
|
||||
enum class FinishMode(val value: Int) {
|
||||
/** Restart playback from the beginning */
|
||||
Loop(0),
|
||||
|
||||
/** Pause at the end of the file */
|
||||
Pause(1),
|
||||
|
||||
/** Stop playback and release resources */
|
||||
Stop(2)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Callback interface for permission request results
|
||||
*
|
||||
* This functional interface is used to notify when a permission request
|
||||
* has been processed, providing the result of the permission check.
|
||||
*/
|
||||
fun interface RequestPermissionsSuccessCallback {
|
||||
/**
|
||||
* Called when permission request completes
|
||||
*
|
||||
* @param results true if all required permissions were granted, false otherwise
|
||||
*/
|
||||
fun onSuccess(results: Boolean?)
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines the possible states of the audio recorder
|
||||
*
|
||||
* These states allow tracking the recorder's lifecycle and determine
|
||||
* which operations are valid at any given time.
|
||||
*/
|
||||
enum class RecorderState {
|
||||
/** Recorder is initialized and ready to start recording */
|
||||
Initialised,
|
||||
|
||||
/** Recorder is actively recording audio */
|
||||
Recording,
|
||||
|
||||
/** Recorder has been temporarily paused but can resume */
|
||||
Paused,
|
||||
|
||||
/** Recording has been stopped (can't resume, but can start a new recording) */
|
||||
Stopped,
|
||||
|
||||
/** Recorder has been disposed and cannot be used anymore */
|
||||
Disposed
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines the supported audio encoders for recording
|
||||
*
|
||||
* This enum provides configuration details for various audio encoders,
|
||||
* including:
|
||||
* - MIME type
|
||||
* - Buffer size
|
||||
* - Output format
|
||||
* - AAC profile (when applicable)
|
||||
* - MediaMuxer usage requirements
|
||||
*
|
||||
* Each encoder has different characteristics in terms of audio quality,
|
||||
* file size, and compatibility across platforms.
|
||||
*/
|
||||
enum class Encoder {
|
||||
/** Uncompressed PCM audio in WAV container */
|
||||
WAV,
|
||||
/** AAC Low Complexity profile - good quality/size balance */
|
||||
AAC_LC,
|
||||
/** AAC High Efficiency profile - better compression than AAC_LC */
|
||||
AAC_HE,
|
||||
/** AAC Enhanced Low Delay - optimized for real-time communication */
|
||||
AAC_ELD,
|
||||
/** Adaptive Multi-Rate Narrowband - speech optimized, low bitrate */
|
||||
AMR_NB,
|
||||
/** Adaptive Multi-Rate Wideband - better speech quality than AMR_NB */
|
||||
AMR_WB,
|
||||
/** Opus codec - versatile audio codec with good compression */
|
||||
OPUS;
|
||||
|
||||
/**
|
||||
* Gets the MIME type string for this encoder
|
||||
*
|
||||
* Used when configuring MediaCodec encoders
|
||||
*/
|
||||
val mimeType: String
|
||||
get() = when (this) {
|
||||
WAV -> MediaFormat.MIMETYPE_AUDIO_RAW
|
||||
AAC_LC, AAC_HE, AAC_ELD -> MediaFormat.MIMETYPE_AUDIO_AAC
|
||||
AMR_NB -> MediaFormat.MIMETYPE_AUDIO_AMR_NB
|
||||
AMR_WB -> MediaFormat.MIMETYPE_AUDIO_AMR_WB
|
||||
OPUS -> MediaFormat.MIMETYPE_AUDIO_OPUS
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the recommended buffer size for this encoder
|
||||
*
|
||||
* Different encoders work optimally with different buffer sizes.
|
||||
* This property returns the recommended size for each encoder type.
|
||||
*/
|
||||
val bufferSize: Int
|
||||
get() = when (this) {
|
||||
AAC_LC -> 2048
|
||||
AAC_HE -> 2048
|
||||
AAC_ELD -> 2048
|
||||
AMR_NB -> 1024
|
||||
AMR_WB -> 2048
|
||||
WAV -> 8192
|
||||
OPUS -> 2048
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the appropriate output format for this encoder
|
||||
*
|
||||
* Maps each encoder to its corresponding container format in MediaRecorder.
|
||||
* For OPUS, uses OGG container format on Android Q and above.
|
||||
*
|
||||
* @throws IllegalArgumentException if WAV is selected (uses raw PCM)
|
||||
* @throws Exception if OPUS is selected on Android below Q
|
||||
*/
|
||||
val toOutputFormat: Int
|
||||
get() = when (this) {
|
||||
WAV -> throw IllegalArgumentException("Illegal format selection.")
|
||||
AAC_LC, AAC_HE, AAC_ELD -> MediaRecorder.OutputFormat.MPEG_4
|
||||
AMR_NB -> MediaRecorder.OutputFormat.AMR_NB
|
||||
AMR_WB -> MediaRecorder.OutputFormat.AMR_WB
|
||||
OPUS -> {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
MediaMuxer.OutputFormat.MUXER_OUTPUT_OGG
|
||||
} else {
|
||||
throw Exception("Minimum android Q is required for $this encoder.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the appropriate MediaMuxer output format for this encoder
|
||||
*
|
||||
* This property is specifically for MediaMuxer, which uses different
|
||||
* constants than MediaRecorder.OutputFormat.
|
||||
*/
|
||||
val toMuxerOutputFormat: Int
|
||||
get() = when (this) {
|
||||
WAV, AMR_NB, AMR_WB -> throw IllegalArgumentException("MediaMuxer not used for this encoder.")
|
||||
AAC_LC, AAC_HE, AAC_ELD -> MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4
|
||||
OPUS -> {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
MediaMuxer.OutputFormat.MUXER_OUTPUT_OGG
|
||||
} else {
|
||||
throw Exception("Minimum android Q is required for $this encoder.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Indicates whether this encoder requires MediaMuxer
|
||||
*
|
||||
* Some encoders (AAC, OPUS) require a container format that needs MediaMuxer
|
||||
* for proper file creation, while others (WAV, AMR) don't.
|
||||
*/
|
||||
val useMediaMuxer: Boolean
|
||||
get() = when (this) {
|
||||
WAV, AMR_NB, AMR_WB -> false
|
||||
AAC_LC, AAC_HE, AAC_ELD, OPUS -> true
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the AAC profile for this encoder if it's an AAC variant
|
||||
*
|
||||
* Returns the appropriate MediaCodecInfo profile constant for AAC encoders,
|
||||
* or null for non-AAC encoders.
|
||||
*/
|
||||
val aacProfile: Int?
|
||||
get() = when (this) {
|
||||
AAC_LC -> MediaCodecInfo.CodecProfileLevel.AACObjectLC
|
||||
AAC_HE -> MediaCodecInfo.CodecProfileLevel.AACObjectHE
|
||||
AAC_ELD -> MediaCodecInfo.CodecProfileLevel.AACObjectELD
|
||||
else -> null
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Creates an Encoder from a string value
|
||||
*
|
||||
* Safely converts a string to its corresponding Encoder enum value.
|
||||
* Returns AAC_LC as a fallback if the string is null or invalid.
|
||||
*
|
||||
* @param value The string representation of the encoder
|
||||
* @return The matching Encoder, or AAC_LC if invalid
|
||||
*/
|
||||
fun fromString(value: String?): Encoder {
|
||||
return try {
|
||||
if (value == null) {
|
||||
Log.e(LOG_TAG, "Encoder type is null. Defaulting to AAC_LC.")
|
||||
return AAC_LC
|
||||
}
|
||||
valueOf(value)
|
||||
} catch (_: IllegalArgumentException) {
|
||||
Log.e(LOG_TAG, "Invalid encoder type: $value. Defaulting to AAC_LC.")
|
||||
AAC_LC
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates if this encoder uses WAV format
|
||||
*
|
||||
* WAV format requires special handling for raw PCM data
|
||||
*/
|
||||
val encodeForWav: Boolean
|
||||
get() {
|
||||
return this == WAV
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates if this encoder is an AAC variant
|
||||
*
|
||||
* AAC encoders may need ADTS headers when writing raw frames
|
||||
*/
|
||||
val isAAC: Boolean
|
||||
get() {
|
||||
return this == AAC_LC || this == AAC_HE || this == AAC_ELD
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,432 @@
|
|||
package com.simform.audio_waveforms
|
||||
|
||||
import android.content.Context
|
||||
import android.media.AudioFormat
|
||||
import android.media.MediaCodec
|
||||
import android.media.MediaExtractor
|
||||
import android.media.MediaFormat
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import java.nio.ByteBuffer
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import kotlin.math.pow
|
||||
import kotlin.math.sqrt
|
||||
import androidx.core.net.toUri
|
||||
|
||||
/**
|
||||
* WaveformExtractor handles the process of extracting amplitude data from audio files
|
||||
* to generate waveform visualizations.
|
||||
*
|
||||
* This class uses the Android MediaCodec API to decode audio files and extract
|
||||
* RMS (Root Mean Square) values at regular intervals, which represent the amplitude
|
||||
* of the audio signal. These values can then be used to draw waveform visualizations.
|
||||
*
|
||||
* The extractor supports various audio formats and bit depths (8-bit, 16-bit, and 32-bit)
|
||||
* and handles both mono and stereo audio channels.
|
||||
*/
|
||||
class WaveformExtractor(
|
||||
/** Path to the audio file to analyze */
|
||||
private val path: String,
|
||||
/** Number of waveform data points to generate */
|
||||
private val expectedPoints: Int,
|
||||
/** Unique identifier for this extraction process */
|
||||
private val key: String,
|
||||
/** Method channel for sending progress updates to Flutter */
|
||||
private val methodChannel: MethodChannel,
|
||||
/** Result callback for sending the final result back to Flutter */
|
||||
private val result: MethodChannel.Result,
|
||||
/** Callback for notifying about progress changes */
|
||||
private val extractorCallBack: ExtractorCallBack,
|
||||
/** Application context for accessing content URIs */
|
||||
private val context: Context,
|
||||
) {
|
||||
/** MediaCodec for decoding audio data */
|
||||
private var decoder: MediaCodec? = null
|
||||
/** MediaExtractor for reading audio tracks from the file */
|
||||
private var extractor: MediaExtractor? = null
|
||||
/** Duration of the audio file in milliseconds */
|
||||
private var durationMillis = 0L
|
||||
/** Current extraction progress (0.0 to 1.0) */
|
||||
private var progress = 0F
|
||||
/** Number of processed chunks */
|
||||
private var currentProgress = 0F
|
||||
|
||||
/** Latch for synchronizing completion of the extraction process */
|
||||
private val finishCount = CountDownLatch(1)
|
||||
/** Flag indicating end of input data */
|
||||
private var inputEof = false
|
||||
/** Sample rate of the audio in Hz */
|
||||
private var sampleRate = 0
|
||||
/** Number of audio channels (1=mono, 2=stereo) */
|
||||
private var channels = 1
|
||||
/** Bit depth of the audio (8, 16, or 32) */
|
||||
private var pcmEncodingBit = 16
|
||||
/** Total number of audio samples */
|
||||
private var totalSamples = 0L
|
||||
/** Number of audio samples per waveform data point */
|
||||
private var perSamplePoints = 0L
|
||||
/** Flag to prevent submitting multiple results */
|
||||
private var isReplySubmitted = false
|
||||
|
||||
/**
|
||||
* Retrieves the audio format from the given media file
|
||||
*
|
||||
* This method:
|
||||
* 1. Creates a MediaExtractor to read the file
|
||||
* 2. Finds the first audio track in the file
|
||||
* 3. Retrieves and selects that track
|
||||
* 4. Extracts the audio duration
|
||||
*
|
||||
* @param path Path to the audio file (content URI format)
|
||||
* @return MediaFormat of the audio track, or null if no audio track is found
|
||||
*/
|
||||
private fun getFormat(path: String): MediaFormat? {
|
||||
if (path.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
val mediaExtractor = MediaExtractor()
|
||||
this.extractor = mediaExtractor
|
||||
val uri = path.toUri()
|
||||
mediaExtractor.setDataSource(context, uri, null)
|
||||
val trackCount = mediaExtractor.trackCount
|
||||
repeat(trackCount) {
|
||||
val format = mediaExtractor.getTrackFormat(it)
|
||||
val mime = format.getString(MediaFormat.KEY_MIME) ?: ""
|
||||
if (mime.contains("audio")) {
|
||||
durationMillis = format.getLong(MediaFormat.KEY_DURATION) / 1000
|
||||
mediaExtractor.selectTrack(it)
|
||||
return format
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the decoding and waveform extraction process
|
||||
*
|
||||
* This method initializes the MediaCodec decoder with appropriate
|
||||
* callbacks to process audio frames. It handles:
|
||||
* 1. Setting up the decoder with the proper format
|
||||
* 2. Processing input buffers from the MediaExtractor
|
||||
* 3. Processing decoded PCM audio data in output buffers
|
||||
* 4. Calculating RMS values for waveform visualization
|
||||
* 5. Reporting progress via the callback interface and method channel
|
||||
*/
|
||||
fun startDecode() {
|
||||
try {
|
||||
val format = getFormat(path) ?: error("No audio format found")
|
||||
val mime = format.getString(MediaFormat.KEY_MIME) ?: error("No MIME type found")
|
||||
decoder = MediaCodec.createDecoderByType(mime).also {
|
||||
it.configure(format, null, null, 0)
|
||||
it.setCallback(object : MediaCodec.Callback() {
|
||||
override fun onInputBufferAvailable(codec: MediaCodec, index: Int) {
|
||||
if (inputEof || index < 0) return
|
||||
val extractor = extractor ?: return
|
||||
codec.getInputBuffer(index)?.let { buf ->
|
||||
val size = extractor.readSampleData(buf, 0)
|
||||
val sampleTime = extractor.sampleTime
|
||||
if (size > 0 && sampleTime >= 0) {
|
||||
try {
|
||||
codec.queueInputBuffer(index, 0, size, sampleTime, 0)
|
||||
extractor.advance()
|
||||
} catch (e: Exception) {
|
||||
inputEof = true
|
||||
result.error(
|
||||
Constants.LOG_TAG,
|
||||
e.message,
|
||||
"Invalid input buffer."
|
||||
)
|
||||
}
|
||||
} else {
|
||||
codec.queueInputBuffer(
|
||||
index,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
MediaCodec.BUFFER_FLAG_END_OF_STREAM
|
||||
)
|
||||
inputEof = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onOutputFormatChanged(codec: MediaCodec, format: MediaFormat) {
|
||||
sampleRate = format.getInteger(MediaFormat.KEY_SAMPLE_RATE)
|
||||
channels = format.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
|
||||
pcmEncodingBit = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
if (format.containsKey(MediaFormat.KEY_PCM_ENCODING)) {
|
||||
when (format.getInteger(MediaFormat.KEY_PCM_ENCODING)) {
|
||||
AudioFormat.ENCODING_PCM_16BIT -> 16
|
||||
AudioFormat.ENCODING_PCM_8BIT -> 8
|
||||
AudioFormat.ENCODING_PCM_FLOAT -> 32
|
||||
else -> 16
|
||||
}
|
||||
} else {
|
||||
16
|
||||
}
|
||||
} else {
|
||||
16
|
||||
}
|
||||
totalSamples = (sampleRate.toLong() * durationMillis) / 1000
|
||||
perSamplePoints = totalSamples / expectedPoints
|
||||
}
|
||||
|
||||
override fun onError(codec: MediaCodec, e: MediaCodec.CodecException) {
|
||||
if (!isReplySubmitted) {
|
||||
result.error(
|
||||
Constants.LOG_TAG,
|
||||
e.message,
|
||||
"An error is thrown while decoding the audio file"
|
||||
)
|
||||
isReplySubmitted = true
|
||||
finishCount.countDown()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onOutputBufferAvailable(
|
||||
codec: MediaCodec,
|
||||
index: Int,
|
||||
info: MediaCodec.BufferInfo
|
||||
) {
|
||||
if (index < 0 || decoder == null) return
|
||||
|
||||
try {
|
||||
if (info.size > 0) {
|
||||
codec.getOutputBuffer(index)?.let { buf ->
|
||||
try {
|
||||
val size = info.size
|
||||
// Set both position and limit to ensure buffer is accessible
|
||||
buf.position(info.offset)
|
||||
buf.limit(info.offset + info.size)
|
||||
|
||||
when (pcmEncodingBit) {
|
||||
8 -> {
|
||||
handle8bit(size, buf)
|
||||
}
|
||||
16 -> {
|
||||
handle16bit(size, buf)
|
||||
}
|
||||
32 -> {
|
||||
handle32bit(size, buf)
|
||||
}
|
||||
else -> {
|
||||
Log.e(Constants.LOG_TAG, "Unsupported PCM encoding bit: $pcmEncodingBit")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(Constants.LOG_TAG, "Error processing output buffer: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// Always release the buffer, even if processing failed
|
||||
try {
|
||||
codec.releaseOutputBuffer(index, false)
|
||||
} catch (e: IllegalStateException) {
|
||||
Log.e(Constants.LOG_TAG, "Error releasing output buffer: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
if (info.isEof()) {
|
||||
updateProgress()
|
||||
val rms = sqrt(sampleSum / perSamplePoints).toFloat()
|
||||
sendProgress(rms)
|
||||
stop()
|
||||
}
|
||||
}
|
||||
|
||||
})
|
||||
it.start()
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
if (!isReplySubmitted) {
|
||||
result.error(
|
||||
Constants.LOG_TAG,
|
||||
e.message,
|
||||
"An error is thrown before decoding the audio file"
|
||||
)
|
||||
isReplySubmitted = true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/** Collected waveform amplitude data points */
|
||||
var sampleData = ArrayList<Float>()
|
||||
/** Count of samples processed for the current data point */
|
||||
private var sampleCount = 0L
|
||||
/** Sum of squared sample values for RMS calculation */
|
||||
private var sampleSum = 0.0
|
||||
|
||||
/**
|
||||
* Processes each audio sample and accumulates data for RMS calculation
|
||||
*
|
||||
* This method:
|
||||
* 1. Accumulates squared sample values
|
||||
* 2. When enough samples are collected for a data point, calculates the RMS
|
||||
* 3. Updates progress and sends the new data point to Flutter
|
||||
*
|
||||
* @param value The normalized audio sample value (-1.0 to 1.0)
|
||||
*/
|
||||
private fun handleBufferDivision(value: Float) {
|
||||
if (sampleCount == perSamplePoints) {
|
||||
updateProgress()
|
||||
|
||||
// Discard redundant values and release resources
|
||||
if (progress > 1.0F) {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
val rms = sqrt(sampleSum / perSamplePoints).toFloat()
|
||||
sendProgress(rms)
|
||||
}
|
||||
|
||||
sampleCount++
|
||||
sampleSum += value.toDouble().pow(2.0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes 8-bit PCM audio data
|
||||
*
|
||||
* Reads 8-bit samples from the buffer, normalizes them to the range [-1.0, 1.0],
|
||||
* and passes them to handleBufferDivision for RMS calculation.
|
||||
*
|
||||
* @param size Size of the buffer in bytes
|
||||
* @param buf ByteBuffer containing the audio data
|
||||
*/
|
||||
private fun handle8bit(size: Int, buf: ByteBuffer) {
|
||||
repeat(size / if (channels == 2) 2 else 1) {
|
||||
val result = buf.get().toInt() / Constants.EIGHT_BITS
|
||||
if (channels == 2) {
|
||||
buf.get()
|
||||
}
|
||||
handleBufferDivision(result)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes 16-bit PCM audio data
|
||||
*
|
||||
* Reads 16-bit samples from the buffer, normalizes them to the range [-1.0, 1.0],
|
||||
* and passes them to handleBufferDivision for RMS calculation.
|
||||
*
|
||||
* @param size Size of the buffer in bytes
|
||||
* @param buf ByteBuffer containing the audio data
|
||||
*/
|
||||
private fun handle16bit(size: Int, buf: ByteBuffer) {
|
||||
repeat(size / if (channels == 2) 4 else 2) {
|
||||
val first = buf.get().toInt()
|
||||
val second = buf.get().toInt() shl 8
|
||||
val value = (first or second) / Constants.SIXTEEN_BITS
|
||||
if (channels == 2) {
|
||||
buf.get()
|
||||
buf.get()
|
||||
}
|
||||
handleBufferDivision(value)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes 32-bit PCM audio data
|
||||
*
|
||||
* Reads 32-bit samples from the buffer, normalizes them to the range [-1.0, 1.0],
|
||||
* and passes them to handleBufferDivision for RMS calculation.
|
||||
*
|
||||
* @param size Size of the buffer in bytes
|
||||
* @param buf ByteBuffer containing the audio data
|
||||
*/
|
||||
private fun handle32bit(size: Int, buf: ByteBuffer) {
|
||||
repeat(size / if (channels == 2) 8 else 4) {
|
||||
val first = buf.get().toLong()
|
||||
val second = buf.get().toLong() shl 8
|
||||
val third = buf.get().toLong() shl 16
|
||||
val forth = buf.get().toLong() shl 24
|
||||
val value = (first or second or third or forth) / Constants.THIRTY_TWO_BITS
|
||||
if (channels == 2) {
|
||||
buf.get()
|
||||
buf.get()
|
||||
buf.get()
|
||||
buf.get()
|
||||
}
|
||||
handleBufferDivision(value)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the extraction progress
|
||||
*
|
||||
* Increments the progress counter and calculates the overall
|
||||
* extraction progress as a ratio of current to expected data points.
|
||||
*/
|
||||
private fun updateProgress() {
|
||||
currentProgress++
|
||||
progress = currentProgress / expectedPoints
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a new waveform data point and current progress to Flutter
|
||||
*
|
||||
* This method:
|
||||
* 1. Adds the new RMS value to the waveform data
|
||||
* 2. Reports the progress via the callback interface
|
||||
* 3. Resets the sample counters for the next data point
|
||||
* 4. Sends the current waveform data and progress to Flutter via the method channel
|
||||
*
|
||||
* @param rms The calculated RMS value for this data point
|
||||
*/
|
||||
private fun sendProgress(rms: Float) {
|
||||
sampleData.add(rms)
|
||||
extractorCallBack.onProgress(progress)
|
||||
sampleCount = 0
|
||||
sampleSum = 0.0
|
||||
|
||||
val args: MutableMap<String, Any?> = HashMap()
|
||||
args[Constants.waveformData] = sampleData
|
||||
args[Constants.progress] = progress
|
||||
args[Constants.playerKey] = key
|
||||
methodChannel.invokeMethod(
|
||||
Constants.onCurrentExtractedWaveformData,
|
||||
args
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the extraction process and releases resources
|
||||
*
|
||||
* This method:
|
||||
* 1. Stops and releases the MediaCodec decoder
|
||||
* 2. Releases the MediaExtractor
|
||||
* 3. Signals completion via the countdown latch
|
||||
*/
|
||||
fun stop() {
|
||||
decoder?.stop()
|
||||
decoder?.release()
|
||||
extractor?.release()
|
||||
finishCount.countDown()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension function to check if a buffer contains the end-of-stream flag
|
||||
*
|
||||
* @return true if this buffer marks the end of the stream, false otherwise
|
||||
*/
|
||||
fun MediaCodec.BufferInfo.isEof() = flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0
|
||||
|
||||
/**
|
||||
* Callback interface for reporting waveform extraction progress
|
||||
*
|
||||
* Implementations of this interface receive progress updates during
|
||||
* the waveform extraction process.
|
||||
*/
|
||||
interface ExtractorCallBack {
|
||||
/**
|
||||
* Called when extraction progress changes
|
||||
*
|
||||
* @param value Progress value from 0.0 to 1.0, where 1.0 indicates completion
|
||||
*/
|
||||
fun onProgress(value: Float)
|
||||
}
|
||||
|
|
@ -0,0 +1,429 @@
|
|||
package com.simform.audio_waveforms.encoders
|
||||
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioRecord
|
||||
import android.media.MediaCodec
|
||||
import android.media.MediaFormat
|
||||
import android.media.MediaMuxer
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import android.util.Log
|
||||
import com.simform.audio_waveforms.Constants
|
||||
import com.simform.audio_waveforms.Encoder
|
||||
import com.simform.audio_waveforms.RecorderSettings
|
||||
import com.simform.audio_waveforms.isEof
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import java.io.FileOutputStream
|
||||
import java.util.LinkedList
|
||||
|
||||
/**
|
||||
* CommonEncoder provides a unified interface for audio encoding functionality across
|
||||
* multiple encoder types (AMR, AAC, etc.). It handles the complexities of:
|
||||
*
|
||||
* - Initializing the appropriate MediaCodec encoder based on the selected encoder type
|
||||
* - Managing input and output buffer processing
|
||||
* - Handling MediaMuxer operations when required (for container formats like MP4)
|
||||
* - Writing encoded audio data to the output file
|
||||
* - Adding format-specific headers like ADTS headers for AAC
|
||||
*
|
||||
* This class acts as a bridge between raw audio data captured from AudioRecord
|
||||
* and the encoded output file ready for playback.
|
||||
*/
|
||||
class CommonEncoder {
|
||||
/** MediaCodec instance for encoding audio data */
|
||||
private lateinit var mediaCodec: MediaCodec
|
||||
|
||||
/** The selected encoder type (AAC_LC, AMR_NB, etc.) */
|
||||
private lateinit var encoder: Encoder
|
||||
|
||||
/** Background thread for encoder operations */
|
||||
private lateinit var handlerThread: HandlerThread
|
||||
|
||||
/** Handler for the encoder thread */
|
||||
private lateinit var handler: Handler
|
||||
|
||||
/** Output stream for writing encoded audio data */
|
||||
private lateinit var outputStream: FileOutputStream
|
||||
|
||||
/** Configuration for the recorder and encoder */
|
||||
private lateinit var recorderSettings: RecorderSettings
|
||||
|
||||
/** MediaMuxer instance for container formats (optional) */
|
||||
private var mediaMuxer: MediaMuxer? = null
|
||||
|
||||
/** Queue for audio data waiting to be encoded */
|
||||
private val inputQueue = LinkedList<ByteArray>()
|
||||
|
||||
/** Current available input buffer index (-1 if none available) */
|
||||
private var currentInputBufferIndex = -1
|
||||
|
||||
/** Flag indicating if the muxer has been started */
|
||||
private var isMuxerStarted = false
|
||||
|
||||
/** Flag indicating encoding process should complete */
|
||||
private var isEncodingComplete = false
|
||||
|
||||
/** Flag indicating encoder has been stopped */
|
||||
private var isEncoderStopped = false
|
||||
|
||||
/** Track index for the audio track in the muxer */
|
||||
private var trackIndex = -1
|
||||
|
||||
/** Callback to invoke when encoding is complete */
|
||||
private var completionCallback: (() -> Unit)? = null
|
||||
|
||||
/** Total bytes encoded so far, used for calculating presentation timestamps */
|
||||
private var totalBytesEncoded = 0L
|
||||
|
||||
/** Track the first output timestamp to normalize subsequent timestamps */
|
||||
private var firstOutputTimestamp = -1L
|
||||
|
||||
/** Track the last valid output timestamp to ensure monotonic increasing */
|
||||
private var lastOutputTimestamp = 0L
|
||||
|
||||
/**
|
||||
* Initializes the MediaCodec encoder with the specified settings
|
||||
*
|
||||
* @param recorderSettings The configuration for the recorder and encoder
|
||||
* @param result The Flutter method channel result to report initialization status
|
||||
* @param onEncodingCompleted Optional callback to invoke when encoding is completed
|
||||
*/
|
||||
fun initCodec(
|
||||
recorderSettings: RecorderSettings,
|
||||
result: MethodChannel.Result,
|
||||
onEncodingCompleted: (() -> Unit)? = null,
|
||||
) {
|
||||
// Reset state at the beginning
|
||||
isMuxerStarted = false
|
||||
trackIndex = -1
|
||||
isEncodingComplete = false
|
||||
isEncoderStopped = false
|
||||
inputQueue.clear()
|
||||
currentInputBufferIndex = -1
|
||||
totalBytesEncoded = 0L
|
||||
firstOutputTimestamp = -1L
|
||||
lastOutputTimestamp = 0L
|
||||
|
||||
this.recorderSettings = recorderSettings
|
||||
encoder = recorderSettings.encoder
|
||||
completionCallback = onEncodingCompleted
|
||||
var useMediaMuxer = encoder.useMediaMuxer
|
||||
var format: MediaFormat
|
||||
try {
|
||||
outputStream = FileOutputStream(recorderSettings.path!!)
|
||||
mediaCodec = MediaCodec.createEncoderByType(encoder.mimeType)
|
||||
|
||||
format = MediaFormat.createAudioFormat(encoder.mimeType, recorderSettings.sampleRate, 1)
|
||||
|
||||
|
||||
recorderSettings.bitRate.let {
|
||||
format.setInteger(MediaFormat.KEY_BIT_RATE, it)
|
||||
}
|
||||
encoder.aacProfile?.let {
|
||||
format.setInteger(
|
||||
MediaFormat.KEY_AAC_PROFILE, it
|
||||
)
|
||||
}
|
||||
format.setInteger(
|
||||
MediaFormat.KEY_MAX_INPUT_SIZE, AudioRecord.getMinBufferSize(
|
||||
recorderSettings.sampleRate, Constants.CHANNEL, AudioFormat.ENCODING_PCM_16BIT
|
||||
)
|
||||
)
|
||||
|
||||
handlerThread = HandlerThread(Constants.ENCODER_THREAD)
|
||||
handlerThread.start()
|
||||
handler = Handler(handlerThread.looper)
|
||||
|
||||
if (encoder == Encoder.AMR_NB) {
|
||||
outputStream.write("#!AMR\n".toByteArray())
|
||||
} else if (encoder == Encoder.AMR_WB) {
|
||||
outputStream.write("#!AMR-WB\n".toByteArray())
|
||||
}
|
||||
|
||||
if (useMediaMuxer) {
|
||||
mediaMuxer = MediaMuxer(recorderSettings.path!!, encoder.toMuxerOutputFormat)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
result.error(
|
||||
Constants.LOG_TAG, "Error initializing encoder: ${e.message}", null
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
mediaCodec.setCallback(object : MediaCodec.Callback() {
|
||||
override fun onInputBufferAvailable(codec: MediaCodec, index: Int) {
|
||||
if (isEncodingComplete && inputQueue.isEmpty()) {
|
||||
// Use the last calculated presentation time for EOF, not system time
|
||||
val eofTimestamp = if (totalBytesEncoded > 0) {
|
||||
val bytesPerSample = 2L
|
||||
val channels = 1L
|
||||
(totalBytesEncoded * 1_000_000L) / (recorderSettings.sampleRate * channels * bytesPerSample)
|
||||
} else {
|
||||
0L
|
||||
}
|
||||
codec.queueInputBuffer(
|
||||
index, 0, 0, eofTimestamp, MediaCodec.BUFFER_FLAG_END_OF_STREAM
|
||||
)
|
||||
} else {
|
||||
currentInputBufferIndex = index
|
||||
feedEncoder()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onOutputBufferAvailable(
|
||||
codec: MediaCodec, index: Int, info: MediaCodec.BufferInfo
|
||||
) {
|
||||
val buffer = codec.getOutputBuffer(index) ?: return
|
||||
|
||||
if (info.isEof()) {
|
||||
codec.releaseOutputBuffer(index, false)
|
||||
stopEncoder()
|
||||
return
|
||||
}
|
||||
|
||||
// Normalize output timestamps to start from 0
|
||||
if (firstOutputTimestamp == -1L && info.presentationTimeUs > 0) {
|
||||
firstOutputTimestamp = info.presentationTimeUs
|
||||
}
|
||||
|
||||
// Calculate normalized timestamp (starting from 0)
|
||||
val normalizedTimestamp = if (firstOutputTimestamp >= 0) {
|
||||
maxOf(info.presentationTimeUs - firstOutputTimestamp, lastOutputTimestamp)
|
||||
} else {
|
||||
info.presentationTimeUs
|
||||
}
|
||||
|
||||
lastOutputTimestamp = normalizedTimestamp
|
||||
|
||||
if (useMediaMuxer) {
|
||||
if (!isMuxerStarted) {
|
||||
trackIndex = mediaMuxer?.addTrack(mediaCodec.outputFormat) ?: -1
|
||||
mediaMuxer?.start()
|
||||
isMuxerStarted = true
|
||||
}
|
||||
|
||||
// Ensure buffer is positioned correctly for MediaMuxer
|
||||
buffer.position(info.offset)
|
||||
buffer.limit(info.offset + info.size)
|
||||
|
||||
// Create a new BufferInfo with normalized timestamp to avoid modifying the original
|
||||
val normalizedInfo = MediaCodec.BufferInfo()
|
||||
normalizedInfo.set(info.offset, info.size, normalizedTimestamp, info.flags)
|
||||
|
||||
// Write sample data with normalized timestamps
|
||||
mediaMuxer?.writeSampleData(trackIndex, buffer, normalizedInfo)
|
||||
} else {
|
||||
// For raw output (AAC/AMR without container), extract the encoded data
|
||||
buffer.position(info.offset)
|
||||
buffer.limit(info.offset + info.size)
|
||||
val encodedData = ByteArray(info.size)
|
||||
buffer.get(encodedData)
|
||||
|
||||
addADTSIfAAC()
|
||||
outputStream.write(encodedData)
|
||||
}
|
||||
|
||||
codec.releaseOutputBuffer(index, false)
|
||||
}
|
||||
|
||||
override fun onError(
|
||||
codec: MediaCodec, e: MediaCodec.CodecException
|
||||
) {
|
||||
Log.e(
|
||||
Constants.LOG_TAG, "Error while encoding: ${e.message}"
|
||||
)
|
||||
stopEncoder()
|
||||
}
|
||||
|
||||
override fun onOutputFormatChanged(
|
||||
codec: MediaCodec, format: MediaFormat
|
||||
) {
|
||||
if ((useMediaMuxer) && !isMuxerStarted) {
|
||||
trackIndex = mediaMuxer?.addTrack(format) ?: -1
|
||||
mediaMuxer?.start()
|
||||
isMuxerStarted = true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
mediaCodec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
|
||||
mediaCodec.start()
|
||||
}
|
||||
|
||||
/**
|
||||
* Queues audio data for encoding
|
||||
*
|
||||
* This method safely adds audio data to the input queue and attempts to feed
|
||||
* it to the encoder if an input buffer is available. Thread-safe.
|
||||
*
|
||||
* @param buffer The raw audio data to encode
|
||||
*/
|
||||
fun queueInputBuffer(buffer: ByteArray) {
|
||||
synchronized(inputQueue) {
|
||||
inputQueue.add(buffer)
|
||||
}
|
||||
|
||||
if (currentInputBufferIndex >= 0) {
|
||||
feedEncoder()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Signals that no more audio data will be provided
|
||||
*
|
||||
* This method marks the encoding process as complete. Once all queued data
|
||||
* is processed, the encoder will be sent an end-of-stream signal.
|
||||
*/
|
||||
fun signalToStop() {
|
||||
isEncodingComplete = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the callback to be invoked when encoding is completed
|
||||
*
|
||||
* @param callback The function to call when encoding completes
|
||||
*/
|
||||
fun setOnEncodingCompleted(callback: () -> Unit) {
|
||||
completionCallback = callback
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Feeds available audio data to the encoder
|
||||
*
|
||||
* This method is called when both audio data is available in the queue
|
||||
* and an input buffer is available from the encoder. It's synchronized
|
||||
* to ensure thread safety when accessing the input queue.
|
||||
*
|
||||
* The presentation timestamp is calculated based on the amount of audio data
|
||||
* encoded so far, starting from 0. This ensures monotonic timestamps that
|
||||
* represent the actual audio timeline for proper playback and looping.
|
||||
*/
|
||||
private fun feedEncoder() {
|
||||
synchronized(inputQueue) {
|
||||
if (inputQueue.isEmpty() || currentInputBufferIndex < 0) return
|
||||
|
||||
val data = inputQueue.poll() ?: return
|
||||
val inputBuffer = mediaCodec.getInputBuffer(currentInputBufferIndex) ?: return
|
||||
inputBuffer.clear()
|
||||
inputBuffer.put(data)
|
||||
|
||||
// Calculate presentation time based on actual audio data encoded
|
||||
// Formula: presentationTimeUs = (totalBytes * 1,000,000) / (sampleRate * channels * bytesPerSample)
|
||||
// For 16-bit PCM mono: bytesPerSample = 2, channels = 1
|
||||
val bytesPerSample = 2L // 16-bit = 2 bytes
|
||||
val channels = 1L // Mono
|
||||
val presentationTimeUs = (totalBytesEncoded * 1_000_000L) / (recorderSettings.sampleRate * channels * bytesPerSample)
|
||||
totalBytesEncoded += data.size
|
||||
|
||||
mediaCodec.queueInputBuffer(
|
||||
currentInputBufferIndex, 0, data.size, presentationTimeUs, 0
|
||||
)
|
||||
currentInputBufferIndex = -1
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds ADTS (Audio Data Transport Stream) header for AAC audio files
|
||||
*
|
||||
* AAC raw frames require an ADTS header to be properly recognized by players.
|
||||
* This method adds the header when necessary based on the encoder type and file extension.
|
||||
*/
|
||||
private fun addADTSIfAAC() {
|
||||
if ((encoder.isAAC && recorderSettings.path!!.endsWith(
|
||||
Constants.AAC_FILE_EXTENSION
|
||||
))
|
||||
) {
|
||||
outputStream.write(addADTSPacket(encoder.bufferSize, recorderSettings.sampleRate))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an ADTS header packet for AAC audio data
|
||||
*
|
||||
* @param dataLength The length of the AAC frame data
|
||||
* @param sampleRate The sample rate of the audio in Hz
|
||||
* @param channelConfig The channel configuration (mono/stereo)
|
||||
* @return The ADTS header as a byte array
|
||||
*/
|
||||
private fun addADTSPacket(
|
||||
dataLength: Int, sampleRate: Int, channelConfig: Int = Constants.CHANNEL
|
||||
): ByteArray {
|
||||
val packet = ByteArray(7)
|
||||
val profile = when (encoder) {
|
||||
Encoder.AAC_LC -> 2
|
||||
Encoder.AAC_HE -> 5
|
||||
Encoder.AAC_ELD -> 39
|
||||
else -> 2
|
||||
}
|
||||
val frameLength = dataLength + 7
|
||||
|
||||
val freqIdx = when (sampleRate) {
|
||||
96000 -> 0
|
||||
88200 -> 1
|
||||
64000 -> 2
|
||||
48000 -> 3
|
||||
44100 -> 4
|
||||
32000 -> 5
|
||||
24000 -> 6
|
||||
22050 -> 7
|
||||
16000 -> 8
|
||||
12000 -> 9
|
||||
11025 -> 10
|
||||
8000 -> 11
|
||||
7350 -> 12
|
||||
else -> 4
|
||||
}
|
||||
|
||||
packet[0] = 0xFF.toByte()
|
||||
packet[1] = 0xF9.toByte()
|
||||
packet[2] = ((profile - 1 shl 6) + (freqIdx shl 2) + (channelConfig shr 2)).toByte()
|
||||
packet[3] = ((channelConfig and 3 shl 6) + (frameLength shr 11)).toByte()
|
||||
packet[4] = (frameLength and 0x7FF shr 3).toByte()
|
||||
packet[5] = ((frameLength and 7 shl 5) + 0x1F).toByte()
|
||||
packet[6] = 0xFC.toByte()
|
||||
|
||||
return packet
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the encoder and releases all resources
|
||||
*
|
||||
* This method is called when encoding is complete or when an error occurs.
|
||||
* It:
|
||||
* 1. Stops and releases the MediaCodec
|
||||
* 2. Stops and releases the MediaMuxer (if used)
|
||||
* 3. Closes the output file stream
|
||||
* 4. Shuts down the handler thread
|
||||
* 5. Calls the completion callback
|
||||
*
|
||||
* This method is designed to be idempotent (can be called multiple times safely).
|
||||
*/
|
||||
private fun stopEncoder() {
|
||||
if (isEncoderStopped) return
|
||||
isEncoderStopped = true
|
||||
|
||||
try {
|
||||
mediaCodec.stop()
|
||||
mediaCodec.release()
|
||||
mediaMuxer?.stop()
|
||||
mediaMuxer?.release()
|
||||
outputStream.close()
|
||||
handlerThread.quitSafely()
|
||||
handlerThread.join()
|
||||
completionCallback?.invoke()
|
||||
} catch (e: Exception) {
|
||||
Log.e(Constants.LOG_TAG, "Error stopping encoder: ${e.message}")
|
||||
}
|
||||
|
||||
// Reset state for next recording
|
||||
isMuxerStarted = false
|
||||
trackIndex = -1
|
||||
isEncodingComplete = false
|
||||
inputQueue.clear()
|
||||
currentInputBufferIndex = -1
|
||||
totalBytesEncoded = 0L
|
||||
firstOutputTimestamp = -1L
|
||||
lastOutputTimestamp = 0L
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
package com.simform.audio_waveforms.encoders
|
||||
|
||||
import com.simform.audio_waveforms.Constants
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.RandomAccessFile
|
||||
|
||||
/**
|
||||
* WavEncoder handles the creation of WAV audio files from raw PCM data.
|
||||
*
|
||||
* This class manages the process of writing PCM audio data to a file with the proper
|
||||
* WAV file format headers. WAV is an uncompressed audio format that preserves the full
|
||||
* quality of the recorded audio but results in larger file sizes compared to
|
||||
* compressed formats like AAC or MP3.
|
||||
*
|
||||
* The encoder works in three main steps:
|
||||
* 1. Start - Creates the file and writes a placeholder header
|
||||
* 2. Write - Appends PCM audio data to the file
|
||||
* 3. Stop - Updates the header with the final file size and audio parameters
|
||||
*/
|
||||
class WavEncoder(
|
||||
/** Target file where the WAV audio will be written */
|
||||
private val wavFile: File,
|
||||
|
||||
/** Sample rate of the audio in Hz (e.g., 44100, 48000) */
|
||||
private val sampleRate: Int
|
||||
) {
|
||||
/** Stream for writing data to the WAV file */
|
||||
private lateinit var outputStream: FileOutputStream
|
||||
|
||||
/** Running count of audio data length in bytes */
|
||||
private var totalAudioLen = 0L
|
||||
|
||||
/** Flag indicating if the encoder is currently active */
|
||||
private var isWriting = false
|
||||
|
||||
/**
|
||||
* Starts the WAV encoding process
|
||||
*
|
||||
* Creates a new file with a placeholder WAV header. The header will be
|
||||
* updated with the correct values when [stop] is called.
|
||||
*
|
||||
* @param result Flutter result callback to report success or errors
|
||||
*/
|
||||
fun start(result: MethodChannel.Result) {
|
||||
isWriting = true
|
||||
try {
|
||||
outputStream = FileOutputStream(wavFile)
|
||||
// These are placeholder bytes for the header which will be updated later.
|
||||
val header = ByteArray(44)
|
||||
outputStream.write(header)
|
||||
} catch (e: Exception) {
|
||||
result.error(
|
||||
"WAV_FILE_WRITE_ERROR",
|
||||
"Error writing to WAV file: ${e.message}",
|
||||
null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes PCM audio data to the WAV file
|
||||
*
|
||||
* Appends raw audio samples to the file and updates the total audio length counter.
|
||||
* This method should be called for each chunk of audio data received from the recorder.
|
||||
*
|
||||
* @param pcmData Array of PCM audio samples to write to the file
|
||||
*/
|
||||
fun writePcmData(pcmData: ByteArray) {
|
||||
if (!isWriting) return
|
||||
outputStream.write(pcmData)
|
||||
totalAudioLen += pcmData.size
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the encoding process and finalizes the WAV file
|
||||
*
|
||||
* This method:
|
||||
* 1. Marks the encoder as no longer writing
|
||||
* 2. Closes the output stream
|
||||
* 3. Updates the WAV header with the final file size and audio parameters
|
||||
*
|
||||
* @param result Flutter result callback to report success or errors
|
||||
*/
|
||||
fun stop(result: MethodChannel.Result) {
|
||||
try {
|
||||
isWriting = false
|
||||
outputStream.close()
|
||||
updateWavHeader()
|
||||
} catch (e: Exception) {
|
||||
result.error(
|
||||
"WAV_FILE_CLOSE_ERROR",
|
||||
"Error closing WAV file: ${e.message}",
|
||||
null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the WAV file header with the final audio parameters
|
||||
*
|
||||
* The WAV format requires a specific header structure with information about:
|
||||
* - File format (RIFF/WAVE)
|
||||
* - Audio format (PCM)
|
||||
* - Number of channels
|
||||
* - Sample rate
|
||||
* - Bit depth
|
||||
* - Total data size
|
||||
*
|
||||
* This method writes all these parameters to the beginning of the file after
|
||||
* all audio data has been written and the total size is known.
|
||||
*/
|
||||
private fun updateWavHeader() {
|
||||
val totalDataLen = totalAudioLen + 36
|
||||
val byteRate = sampleRate * Constants.CHANNEL * Constants.BIT_PER_SAMPLE / 8
|
||||
val header = ByteArray(44)
|
||||
|
||||
/**
|
||||
* Writes a 32-bit integer to the header in little-endian format
|
||||
*
|
||||
* @param offset Starting position in the header array
|
||||
* @param value Integer value to write
|
||||
*/
|
||||
fun writeInt(offset: Int, value: Int) {
|
||||
header[offset] = (value and 0xff).toByte()
|
||||
header[offset + 1] = ((value shr 8) and 0xff).toByte()
|
||||
header[offset + 2] = ((value shr 16) and 0xff).toByte()
|
||||
header[offset + 3] = ((value shr 24) and 0xff).toByte()
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a 16-bit short to the header in little-endian format
|
||||
*
|
||||
* @param offset Starting position in the header array
|
||||
* @param value Short value to write
|
||||
*/
|
||||
fun writeShort(offset: Int, value: Short) {
|
||||
header[offset] = (value.toInt() and 0xff).toByte()
|
||||
header[offset + 1] = ((value.toInt() shr 8) and 0xff).toByte()
|
||||
}
|
||||
|
||||
// RIFF chunk descriptor
|
||||
"RIFF".toByteArray().copyInto(header, 0) // ChunkID: "RIFF" in ASCII
|
||||
writeInt(4, totalDataLen.toInt()) // ChunkSize: total size minus 8 bytes
|
||||
"WAVE".toByteArray().copyInto(header, 8) // Format: "WAVE" in ASCII
|
||||
|
||||
// "fmt " sub-chunk (format information)
|
||||
"fmt ".toByteArray().copyInto(header, 12) // Subchunk1ID: "fmt " in ASCII
|
||||
writeInt(16, 16) // Subchunk1Size: 16 for PCM format
|
||||
writeShort(20, 1) // AudioFormat: 1 for PCM (uncompressed)
|
||||
writeShort(22, Constants.CHANNEL.toShort()) // NumChannels: Mono=1, Stereo=2
|
||||
writeInt(24, sampleRate) // SampleRate: samples per second
|
||||
writeInt(28, byteRate) // ByteRate: bytes per second
|
||||
writeShort(32, (Constants.CHANNEL * Constants.BIT_PER_SAMPLE / 8).toShort()) // BlockAlign
|
||||
writeShort(34, Constants.BIT_PER_SAMPLE.toShort()) // BitsPerSample: 8, 16, etc.
|
||||
|
||||
// "data" sub-chunk (the actual sound data)
|
||||
"data".toByteArray().copyInto(header, 36) // Subchunk2ID: "data" in ASCII
|
||||
writeInt(40, totalAudioLen.toInt()) // Subchunk2Size: size of the audio data
|
||||
|
||||
// Write the completed header back to the beginning of the file
|
||||
val raf = RandomAccessFile(wavFile, "rw")
|
||||
raf.seek(0)
|
||||
raf.write(header)
|
||||
raf.close()
|
||||
}
|
||||
}
|
||||
38
audio_waveforms/ios/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
.idea/
|
||||
.vagrant/
|
||||
.sconsign.dblite
|
||||
.svn/
|
||||
|
||||
.DS_Store
|
||||
*.swp
|
||||
profile
|
||||
|
||||
DerivedData/
|
||||
build/
|
||||
GeneratedPluginRegistrant.h
|
||||
GeneratedPluginRegistrant.m
|
||||
|
||||
.generated/
|
||||
|
||||
*.pbxuser
|
||||
*.mode1v3
|
||||
*.mode2v3
|
||||
*.perspectivev3
|
||||
|
||||
!default.pbxuser
|
||||
!default.mode1v3
|
||||
!default.mode2v3
|
||||
!default.perspectivev3
|
||||
|
||||
xcuserdata
|
||||
|
||||
*.moved-aside
|
||||
|
||||
*.pyc
|
||||
*sync/
|
||||
Icon?
|
||||
.tags*
|
||||
|
||||
/Flutter/Generated.xcconfig
|
||||
/Flutter/ephemeral/
|
||||
/Flutter/flutter_export_environment.sh
|
||||
174
audio_waveforms/ios/Classes/AudioPlayer.swift
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
import Foundation
|
||||
|
||||
import AVKit
|
||||
|
||||
class AudioPlayer: NSObject, AVAudioPlayerDelegate {
|
||||
private var seekToStart = true
|
||||
private var stopWhenCompleted = false
|
||||
private var timer: Timer?
|
||||
private var player: AVAudioPlayer?
|
||||
private var finishMode:FinishMode = FinishMode.stop
|
||||
private var updateFrequency = 200
|
||||
var plugin: SwiftAudioWaveformsPlugin
|
||||
var playerKey: String
|
||||
var flutterChannel: FlutterMethodChannel
|
||||
|
||||
|
||||
init(plugin: SwiftAudioWaveformsPlugin, playerKey: String, channel: FlutterMethodChannel) {
|
||||
self.plugin = plugin
|
||||
self.playerKey = playerKey
|
||||
flutterChannel = channel
|
||||
}
|
||||
|
||||
func preparePlayer(path: String?, volume: Double?, updateFrequency: Int?,result: @escaping FlutterResult, overrideAudioSession : Bool) {
|
||||
if(!(path ?? "").isEmpty) {
|
||||
self.updateFrequency = updateFrequency ?? 200
|
||||
let audioUrl = URL.init(string: path!)
|
||||
if(audioUrl == nil){
|
||||
result(FlutterError(code: Constants.audioWaveforms, message: "Failed to initialise Url from provided audio file", details: "If path contains `file://` try removing it"))
|
||||
return
|
||||
}
|
||||
do {
|
||||
stopPlayer()
|
||||
player = nil
|
||||
player = try AVAudioPlayer(contentsOf: audioUrl!)
|
||||
do {
|
||||
if overrideAudioSession {
|
||||
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default)
|
||||
try AVAudioSession.sharedInstance().setActive(true)
|
||||
}
|
||||
} catch {
|
||||
result(FlutterError(code: Constants.audioWaveforms, message: "Couldn't set audio session.", details: error.localizedDescription))
|
||||
return
|
||||
}
|
||||
|
||||
} catch {
|
||||
result(FlutterError(code: Constants.audioWaveforms, message: "Failed to prepare player", details: error.localizedDescription))
|
||||
return
|
||||
}
|
||||
player?.enableRate = true
|
||||
player?.rate = 1.0
|
||||
player?.prepareToPlay()
|
||||
player?.volume = Float(volume ?? 1.0)
|
||||
result(true)
|
||||
} else {
|
||||
result(FlutterError(code: Constants.audioWaveforms, message: "Audio file path can't be empty or null", details: nil))
|
||||
}
|
||||
}
|
||||
|
||||
func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer,successfully flag: Bool) {
|
||||
var finishType = 2
|
||||
|
||||
switch self.finishMode{
|
||||
|
||||
case .loop:
|
||||
self.player?.currentTime = 0
|
||||
self.player?.play()
|
||||
finishType = 0
|
||||
|
||||
case .pause:
|
||||
self.player?.pause()
|
||||
stopListening()
|
||||
finishType = 1
|
||||
|
||||
case .stop:
|
||||
self.player?.stop()
|
||||
stopListening()
|
||||
self.player = nil
|
||||
finishType = 2
|
||||
|
||||
|
||||
}
|
||||
|
||||
plugin.flutterChannel.invokeMethod(Constants.onDidFinishPlayingAudio, arguments: [
|
||||
Constants.finishType: finishType,
|
||||
Constants.playerKey: playerKey])
|
||||
|
||||
}
|
||||
|
||||
func startPlyer(result: @escaping FlutterResult) {
|
||||
player?.play()
|
||||
player?.delegate = self
|
||||
startListening()
|
||||
result(true)
|
||||
}
|
||||
|
||||
|
||||
func pausePlayer() {
|
||||
stopListening()
|
||||
player?.pause()
|
||||
}
|
||||
|
||||
func stopPlayer() {
|
||||
stopListening()
|
||||
player?.stop()
|
||||
timer = nil
|
||||
}
|
||||
|
||||
func release(result: @escaping FlutterResult) {
|
||||
player = nil
|
||||
result(true)
|
||||
}
|
||||
|
||||
func getDuration(_ type: DurationType, _ result: @escaping FlutterResult) throws {
|
||||
if type == .Current {
|
||||
let ms = (player?.currentTime ?? 0) * 1000
|
||||
result(Int(ms))
|
||||
} else {
|
||||
let ms = (player?.duration ?? 0) * 1000
|
||||
result(Int(ms))
|
||||
}
|
||||
}
|
||||
|
||||
func setVolume(_ volume: Double?, _ result: @escaping FlutterResult) {
|
||||
player?.volume = Float(volume ?? 1.0)
|
||||
result(true)
|
||||
}
|
||||
|
||||
func setRate(_ rate: Double?, _ result: @escaping FlutterResult) {
|
||||
player?.rate = Float(rate ?? 1.0);
|
||||
result(true)
|
||||
}
|
||||
|
||||
func seekTo(_ time: Int?, _ result: @escaping FlutterResult) {
|
||||
if(time != nil) {
|
||||
player?.currentTime = Double(time! / 1000)
|
||||
sendCurrentDuration()
|
||||
result(true)
|
||||
} else {
|
||||
result(false)
|
||||
}
|
||||
}
|
||||
|
||||
func setFinishMode(result : @escaping FlutterResult, releaseType : Int?){
|
||||
if(releaseType != nil && releaseType == 0){
|
||||
self.finishMode = FinishMode.loop
|
||||
}else if(releaseType != nil && releaseType == 1){
|
||||
self.finishMode = FinishMode.pause
|
||||
}else{
|
||||
self.finishMode = FinishMode.stop
|
||||
}
|
||||
result(nil)
|
||||
}
|
||||
|
||||
func startListening() {
|
||||
if #available(iOS 10.0, *) {
|
||||
timer = Timer.scheduledTimer(withTimeInterval: (Double(updateFrequency) / 1000), repeats: true, block: { _ in
|
||||
self.sendCurrentDuration()
|
||||
})
|
||||
} else {
|
||||
// Fallback on earlier versions
|
||||
}
|
||||
}
|
||||
|
||||
func stopListening() {
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
sendCurrentDuration()
|
||||
}
|
||||
|
||||
func sendCurrentDuration() {
|
||||
let ms = (player?.currentTime ?? 0) * 1000
|
||||
flutterChannel.invokeMethod(Constants.onCurrentDuration, arguments: [Constants.current: Int(ms), Constants.playerKey: playerKey])
|
||||
}
|
||||
}
|
||||
194
audio_waveforms/ios/Classes/AudioRecorder.swift
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
import AVFoundation
|
||||
import Accelerate
|
||||
|
||||
public class AudioRecorder: NSObject, AVAudioRecorderDelegate{
|
||||
var audioRecorder: AVAudioRecorder?
|
||||
var path: String?
|
||||
var useLegacyNormalization: Bool = false
|
||||
var audioUrl: URL?
|
||||
var recordedDuration: CMTime = CMTime.zero
|
||||
var flutterChannel: FlutterMethodChannel
|
||||
var bytesStreamEngine: RecorderBytesStreamEngine
|
||||
init(channel: FlutterMethodChannel){
|
||||
flutterChannel = channel
|
||||
bytesStreamEngine = RecorderBytesStreamEngine(channel: channel)
|
||||
}
|
||||
|
||||
func startRecording(_ result: @escaping FlutterResult,_ recordingSettings: RecordingSettings){
|
||||
useLegacyNormalization = recordingSettings.useLegacy ?? false
|
||||
|
||||
var settings: [String: Any] = [
|
||||
AVFormatIDKey: getEncoder(recordingSettings.encoder ?? 0),
|
||||
AVSampleRateKey: recordingSettings.sampleRate ?? 44100,
|
||||
AVNumberOfChannelsKey: 1,
|
||||
AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue
|
||||
]
|
||||
|
||||
if (recordingSettings.bitRate != nil) {
|
||||
settings[AVEncoderBitRateKey] = recordingSettings.bitRate
|
||||
}
|
||||
|
||||
if ((recordingSettings.encoder ?? 0) == Constants.kAudioFormatLinearPCM) {
|
||||
settings[AVLinearPCMBitDepthKey] = recordingSettings.linearPCMBitDepth
|
||||
settings[AVLinearPCMIsBigEndianKey] = recordingSettings.linearPCMIsBigEndian
|
||||
settings[AVLinearPCMIsFloatKey] = recordingSettings.linearPCMIsFloat
|
||||
}
|
||||
|
||||
let options: AVAudioSession.CategoryOptions = [.defaultToSpeaker, .allowBluetooth]
|
||||
if (recordingSettings.path == nil) {
|
||||
let documentDirectory = getDocumentDirectory(result)
|
||||
let date = Date()
|
||||
let dateFormatter = DateFormatter()
|
||||
dateFormatter.dateFormat = recordingSettings.fileNameFormat
|
||||
let fileName = dateFormatter.string(from: date) + ".m4a"
|
||||
self.path = "\(documentDirectory)/\(fileName)"
|
||||
} else {
|
||||
self.path = recordingSettings.path
|
||||
}
|
||||
|
||||
|
||||
do {
|
||||
if recordingSettings.overrideAudioSession {
|
||||
try AVAudioSession.sharedInstance().setCategory(.playAndRecord, options: options)
|
||||
try AVAudioSession.sharedInstance().setActive(true)
|
||||
}
|
||||
audioUrl = URL(fileURLWithPath: self.path!)
|
||||
|
||||
if(audioUrl == nil){
|
||||
result(FlutterError(code: Constants.audioWaveforms, message: "Failed to initialise file URL", details: nil))
|
||||
return
|
||||
}
|
||||
audioRecorder = try AVAudioRecorder(url: audioUrl!, settings: settings as [String : Any])
|
||||
|
||||
audioRecorder?.delegate = self
|
||||
audioRecorder?.isMeteringEnabled = true
|
||||
audioRecorder?.record()
|
||||
bytesStreamEngine
|
||||
.attach(
|
||||
result: result,
|
||||
sampleRate: recordingSettings.sampleRate ?? Constants.defaultSampleRate
|
||||
)
|
||||
result(true)
|
||||
} catch {
|
||||
result(FlutterError(code: Constants.audioWaveforms, message: "Failed to start recording", details: error.localizedDescription))
|
||||
}
|
||||
}
|
||||
|
||||
public func stopRecording(_ result: @escaping FlutterResult) {
|
||||
audioRecorder?.stop()
|
||||
bytesStreamEngine.detach()
|
||||
if(audioUrl != nil) {
|
||||
let asset = AVURLAsset(url: audioUrl!)
|
||||
|
||||
if #available(iOS 15.0, *) {
|
||||
Task {
|
||||
do {
|
||||
recordedDuration = try await asset.load(.duration)
|
||||
sendResult(result, duration: Int(recordedDuration.seconds * 1000))
|
||||
} catch let err {
|
||||
debugPrint(err.localizedDescription)
|
||||
sendResult(result, duration: Int(CMTime.zero.seconds))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
recordedDuration = asset.duration
|
||||
sendResult(result, duration: Int(recordedDuration.seconds * 1000))
|
||||
}
|
||||
} else {
|
||||
sendResult(result, duration: Int(CMTime.zero.seconds))
|
||||
}
|
||||
audioRecorder = nil
|
||||
}
|
||||
|
||||
private func sendResult(_ result: @escaping FlutterResult, duration:Int){
|
||||
var params = [String:Any?]()
|
||||
params[Constants.resultFilePath] = path
|
||||
params[Constants.resultDuration] = duration
|
||||
result(params)
|
||||
}
|
||||
|
||||
public func pauseRecording(_ result: @escaping FlutterResult) {
|
||||
audioRecorder?.pause()
|
||||
bytesStreamEngine.togglePause()
|
||||
result(false)
|
||||
}
|
||||
|
||||
public func resumeRecording(_ result: @escaping FlutterResult) {
|
||||
audioRecorder?.record()
|
||||
bytesStreamEngine.togglePause();
|
||||
result(true)
|
||||
}
|
||||
|
||||
public func getDecibel(_ result: @escaping FlutterResult) {
|
||||
audioRecorder?.updateMeters()
|
||||
if(useLegacyNormalization){
|
||||
let amp = audioRecorder?.averagePower(forChannel: 0) ?? 0.0
|
||||
result(amp)
|
||||
} else {
|
||||
let amp = audioRecorder?.peakPower(forChannel: 0) ?? 0.0
|
||||
let linear = pow(10, amp / 20);
|
||||
result(linear)
|
||||
}
|
||||
}
|
||||
|
||||
public func checkHasPermission(_ result: @escaping FlutterResult){
|
||||
switch AVAudioSession.sharedInstance().recordPermission{
|
||||
|
||||
case .undetermined:
|
||||
AVAudioSession.sharedInstance().requestRecordPermission() { [unowned self] allowed in
|
||||
DispatchQueue.main.async {
|
||||
result(allowed)
|
||||
}
|
||||
}
|
||||
case .denied:
|
||||
result(false)
|
||||
case .granted:
|
||||
result(true)
|
||||
@unknown default:
|
||||
result(false)
|
||||
}
|
||||
}
|
||||
public func getEncoder(_ enCoder: Int) -> Int {
|
||||
switch(enCoder) {
|
||||
case Constants.kAudioFormatMPEG4AAC:
|
||||
return Int(kAudioFormatMPEG4AAC)
|
||||
case Constants.kAudioFormatMPEGLayer1:
|
||||
return Int(kAudioFormatMPEGLayer1)
|
||||
case Constants.kAudioFormatMPEGLayer2:
|
||||
return Int(kAudioFormatMPEGLayer2)
|
||||
case Constants.kAudioFormatMPEGLayer3:
|
||||
return Int(kAudioFormatMPEGLayer3)
|
||||
case Constants.kAudioFormatMPEG4AAC_ELD:
|
||||
return Int(kAudioFormatMPEG4AAC_ELD)
|
||||
case Constants.kAudioFormatMPEG4AAC_HE:
|
||||
return Int(kAudioFormatMPEG4AAC_HE)
|
||||
case Constants.kAudioFormatOpus:
|
||||
return Int(kAudioFormatOpus)
|
||||
case Constants.kAudioFormatAMR:
|
||||
return Int(kAudioFormatAMR)
|
||||
case Constants.kAudioFormatAMR_WB:
|
||||
return Int(kAudioFormatAMR_WB)
|
||||
case Constants.kAudioFormatLinearPCM:
|
||||
return Int(kAudioFormatLinearPCM)
|
||||
case Constants.kAudioFormatAppleLossless:
|
||||
return Int(kAudioFormatAppleLossless)
|
||||
case Constants.kAudioFormatMPEG4AAC_HE_V2:
|
||||
return Int(kAudioFormatMPEG4AAC_HE_V2)
|
||||
default:
|
||||
return Int(kAudioFormatMPEG4AAC)
|
||||
}
|
||||
}
|
||||
|
||||
private func getDocumentDirectory(_ result: @escaping FlutterResult) -> String {
|
||||
let directory = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)[0]
|
||||
let ifExists = FileManager.default.fileExists(atPath: directory)
|
||||
if(directory.isEmpty){
|
||||
result(FlutterError(code: Constants.audioWaveforms, message: "The document directory path is empty", details: nil))
|
||||
return ""
|
||||
} else if(!ifExists) {
|
||||
result(FlutterError(code: Constants.audioWaveforms, message: "The document directory does't exists", details: nil))
|
||||
return ""
|
||||
}
|
||||
return directory
|
||||
}
|
||||
}
|
||||
4
audio_waveforms/ios/Classes/AudioWaveformsPlugin.h
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
#import <Flutter/Flutter.h>
|
||||
|
||||
@interface AudioWaveformsPlugin : NSObject<FlutterPlugin>
|
||||
@end
|
||||
15
audio_waveforms/ios/Classes/AudioWaveformsPlugin.m
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
#import "AudioWaveformsPlugin.h"
|
||||
#if __has_include(<audio_waveforms/audio_waveforms-Swift.h>)
|
||||
#import <audio_waveforms/audio_waveforms-Swift.h>
|
||||
#else
|
||||
// Support project import fallback if the generated compatibility header
|
||||
// is not copied when this plugin is created as a library.
|
||||
// https://forums.swift.org/t/swift-static-libraries-dont-copy-generated-objective-c-header/19816
|
||||
#import "audio_waveforms-Swift.h"
|
||||
#endif
|
||||
|
||||
@implementation AudioWaveformsPlugin
|
||||
+ (void)registerWithRegistrar:(NSObject<FlutterPluginRegistrar>*)registrar {
|
||||
[SwiftAudioWaveformsPlugin registerWithRegistrar:registrar];
|
||||
}
|
||||
@end
|
||||
98
audio_waveforms/ios/Classes/RecorderBytesStreamEngine.swift
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
//
|
||||
// RecorderBytesStreamHandler.swift
|
||||
// audio_waveforms
|
||||
//
|
||||
// Created by Ujas Majithiya on 10/04/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import AVFAudio
|
||||
import Accelerate
|
||||
|
||||
class RecorderBytesStreamEngine {
|
||||
private var audioEngine = AVAudioEngine()
|
||||
private var flutterChannel: FlutterMethodChannel
|
||||
private var paused: Bool = false
|
||||
private var totalFrames: AVAudioFramePosition = 0
|
||||
|
||||
init(channel: FlutterMethodChannel) {
|
||||
flutterChannel = channel
|
||||
}
|
||||
|
||||
func attach(result: @escaping FlutterResult, sampleRate: Int) {
|
||||
let inputNode = audioEngine.inputNode
|
||||
inputNode.installTap(onBus: 0, bufferSize: 1024, format: nil) { (buffer, time) in
|
||||
if self.paused {
|
||||
return
|
||||
}
|
||||
if let (convertedBytes, normalizedRms) = self.convertToFlutterType(buffer) {
|
||||
self.totalFrames += AVAudioFramePosition(buffer.frameLength)
|
||||
let effectiveSampleRate = buffer.format.sampleRate > 0 ? buffer.format.sampleRate : Double(sampleRate)
|
||||
let duration = Double(self.totalFrames) / effectiveSampleRate
|
||||
let milliseconds = Int(duration * 1000)
|
||||
self.sendToFlutter(
|
||||
convertedBytes,
|
||||
normalizedRms: normalizedRms,
|
||||
milliSeconds: milliseconds
|
||||
)
|
||||
}
|
||||
}
|
||||
do {
|
||||
try audioEngine.start()
|
||||
} catch {
|
||||
result(FlutterError(code: Constants.audioWaveforms, message: "Error starting Audio Engine", details: error.localizedDescription))
|
||||
}
|
||||
}
|
||||
|
||||
func togglePause() {
|
||||
paused = !paused
|
||||
}
|
||||
|
||||
func detach() {
|
||||
totalFrames = 0
|
||||
audioEngine.inputNode.removeTap(onBus: 0)
|
||||
audioEngine.stop()
|
||||
}
|
||||
|
||||
private func convertToFlutterType(_ buffer: AVAudioPCMBuffer) -> (FlutterStandardTypedData, Double)? {
|
||||
guard let channelData = buffer.floatChannelData?[0] else { return nil }
|
||||
let frameLength = Int(buffer.frameLength)
|
||||
|
||||
// Convert Float32 buffer to UInt8 (byte array)
|
||||
var audioSamples = [Float32](repeating: 0.0, count: frameLength)
|
||||
var sumOfSquares: Float = 0.0
|
||||
|
||||
for i in 0..<frameLength {
|
||||
audioSamples[i] = channelData[i]
|
||||
sumOfSquares += channelData[i] * channelData[i]
|
||||
}
|
||||
|
||||
// Calculate RMS
|
||||
var rms: Float = 0.0
|
||||
vDSP_rmsqv(
|
||||
audioSamples, 1, &rms,
|
||||
vDSP_Length(frameLength)
|
||||
)
|
||||
|
||||
// Normalize RMS to 0-1 range (assuming max amplitude is 1.0 for Float32)
|
||||
let normalizedRms = Double(min(rms, 1.0))
|
||||
|
||||
let byteBuffer = audioSamples.withUnsafeBufferPointer { bufferPointer in
|
||||
return Data(buffer: bufferPointer)
|
||||
}
|
||||
let convertedBuffer = FlutterStandardTypedData(bytes: byteBuffer)
|
||||
return (convertedBuffer, normalizedRms)
|
||||
|
||||
}
|
||||
|
||||
private func sendToFlutter(_ buffer: FlutterStandardTypedData, normalizedRms: Double, milliSeconds: Int) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
flutterChannel.invokeMethod(Constants.onAudioChunk, arguments: [
|
||||
Constants.bytes: buffer,
|
||||
Constants.normalisedRms: normalizedRms,
|
||||
Constants.recordedDuration: milliSeconds,
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
47
audio_waveforms/ios/Classes/RecordingSettings.swift
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
//
|
||||
// RecordingSettings.swift
|
||||
// audio_waveforms
|
||||
//
|
||||
// Created by Manoj Padiya on 30/12/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct RecordingSettings {
|
||||
var path: String?
|
||||
var encoder : Int?
|
||||
var sampleRate : Int?
|
||||
var bitRate : Int?
|
||||
var fileNameFormat : String
|
||||
var useLegacy : Bool?
|
||||
var overrideAudioSession : Bool
|
||||
var linearPCMBitDepth : Int
|
||||
var linearPCMIsBigEndian : Bool
|
||||
var linearPCMIsFloat : Bool
|
||||
|
||||
static func fromJson(_ json: [String: Any]) -> RecordingSettings {
|
||||
let path = json[Constants.path] as? String
|
||||
let encoder = json[Constants.encoder] as? Int
|
||||
let sampleRate = json[Constants.sampleRate] as? Int
|
||||
let bitRate = json[Constants.bitRate] as? Int
|
||||
let fileNameFormat = Constants.fileNameFormat
|
||||
let useLegacy = json[Constants.useLegacyNormalization] as? Bool
|
||||
let overrideAudioSession = json[Constants.overrideAudioSession] as? Bool ?? true
|
||||
let linearPCMBitDepth = json[Constants.linearPCMBitDepth] as? Int ?? 16
|
||||
let linearPCMIsBigEndian = json[Constants.linearPCMIsBigEndian] as? Bool ?? false
|
||||
let linearPCMIsFloat = json[Constants.linearPCMIsFloat] as? Bool ?? false
|
||||
|
||||
return RecordingSettings(
|
||||
path: path,
|
||||
encoder: encoder,
|
||||
sampleRate: sampleRate,
|
||||
bitRate: bitRate,
|
||||
fileNameFormat: fileNameFormat,
|
||||
useLegacy: useLegacy,
|
||||
overrideAudioSession: overrideAudioSession,
|
||||
linearPCMBitDepth: linearPCMBitDepth,
|
||||
linearPCMIsBigEndian: linearPCMIsBigEndian,
|
||||
linearPCMIsFloat: linearPCMIsFloat
|
||||
)
|
||||
}
|
||||
}
|
||||
219
audio_waveforms/ios/Classes/SwiftAudioWaveformsPlugin.swift
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
import Flutter
|
||||
import UIKit
|
||||
|
||||
public class SwiftAudioWaveformsPlugin: NSObject, FlutterPlugin {
|
||||
|
||||
let audioRecorder: AudioRecorder
|
||||
var audioPlayers = [String: AudioPlayer]()
|
||||
var extractors = [String: WaveformExtractor]()
|
||||
var flutterChannel: FlutterMethodChannel
|
||||
|
||||
init(registrar: FlutterPluginRegistrar, flutterChannel: FlutterMethodChannel) {
|
||||
self.flutterChannel = flutterChannel
|
||||
audioRecorder = AudioRecorder(channel: flutterChannel)
|
||||
super.init()
|
||||
}
|
||||
|
||||
deinit {
|
||||
audioPlayers.removeAll()
|
||||
extractors.removeAll()
|
||||
}
|
||||
public static func register(with registrar: FlutterPluginRegistrar) {
|
||||
let channel = FlutterMethodChannel(name: Constants.methodChannelName, binaryMessenger: registrar.messenger())
|
||||
let instance = SwiftAudioWaveformsPlugin(registrar: registrar, flutterChannel: channel)
|
||||
registrar.addMethodCallDelegate(instance, channel: channel)
|
||||
}
|
||||
|
||||
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
let args = call.arguments as? Dictionary<String, Any>
|
||||
switch call.method {
|
||||
case Constants.startRecording:
|
||||
guard let args = call.arguments as? Dictionary<String, Any> else {
|
||||
result(FlutterError(code: Constants.audioWaveforms, message: "Invalid Arguments", details: nil))
|
||||
return
|
||||
}
|
||||
audioRecorder.startRecording(result, RecordingSettings.fromJson((args)))
|
||||
break
|
||||
case Constants.pauseRecording:
|
||||
audioRecorder.pauseRecording(result)
|
||||
break
|
||||
case Constants.resumeRecording:
|
||||
audioRecorder.resumeRecording(result)
|
||||
case Constants.stopRecording:
|
||||
audioRecorder.stopRecording(result)
|
||||
break
|
||||
case Constants.getDecibel:
|
||||
audioRecorder.getDecibel(result)
|
||||
break
|
||||
case Constants.checkPermission:
|
||||
audioRecorder.checkHasPermission(result)
|
||||
break
|
||||
case Constants.preparePlayer:
|
||||
let key = args?[Constants.playerKey] as? String
|
||||
if(key != nil){
|
||||
initPlayer(playerKey: key!)
|
||||
audioPlayers[key!]?.preparePlayer(path: args?[Constants.path] as? String,
|
||||
volume: args?[Constants.volume] as? Double,
|
||||
updateFrequency: args?[Constants.updateFrequency] as? Int,
|
||||
result: result,
|
||||
overrideAudioSession: (args?[Constants.overrideAudioSession] as? Bool) ?? false)
|
||||
} else {
|
||||
result(FlutterError(code: Constants.audioWaveforms, message: "Can not prepare player", details: "Player key is null"))
|
||||
}
|
||||
break
|
||||
case Constants.startPlayer:
|
||||
let key = args?[Constants.playerKey] as? String
|
||||
if(key != nil){
|
||||
audioPlayers[key!]?.startPlyer(result: result)
|
||||
} else {
|
||||
result(FlutterError(code: Constants.audioWaveforms, message: "Can not start player", details: "Player key is null"))
|
||||
}
|
||||
break
|
||||
case Constants.finishMode:
|
||||
let key = args?[Constants.playerKey] as? String
|
||||
let releaseType = args?[Constants.finishType] as? Int
|
||||
if(key != nil){
|
||||
audioPlayers[key!]?.setFinishMode(result: result, releaseType: releaseType)
|
||||
}else{
|
||||
result(FlutterError(code: Constants.audioWaveforms, message: "Can not set release mode", details: "Player key is null"))
|
||||
}
|
||||
case Constants.pausePlayer:
|
||||
let key = args?[Constants.playerKey] as? String
|
||||
if(key != nil){
|
||||
audioPlayers[key!]?.pausePlayer()
|
||||
result(true)
|
||||
} else {
|
||||
result(FlutterError(code: Constants.audioWaveforms, message: "Can not pause player", details: "Player key is null"))
|
||||
}
|
||||
break
|
||||
case Constants.stopPlayer:
|
||||
let key = args?[Constants.playerKey] as? String
|
||||
if(key != nil){
|
||||
audioPlayers[key!]?.stopPlayer()
|
||||
result(true)
|
||||
} else {
|
||||
result(FlutterError(code: Constants.audioWaveforms, message: "Can not stop player", details: "Player key is null"))
|
||||
}
|
||||
break
|
||||
case Constants.releasePlayer:
|
||||
let key = args?[Constants.playerKey] as? String
|
||||
if(key != nil){
|
||||
audioPlayers[key!]?.release(result: result)
|
||||
}
|
||||
break;
|
||||
case Constants.seekTo:
|
||||
let key = args?[Constants.playerKey] as? String
|
||||
if(key != nil){
|
||||
audioPlayers[key!]?.seekTo(args?[Constants.progress] as? Int,result)
|
||||
} else {
|
||||
result(FlutterError(code: Constants.audioWaveforms, message: "Can not seek to postion", details: "Player key is null"))
|
||||
}
|
||||
case Constants.setVolume:
|
||||
let key = args?[Constants.playerKey] as? String
|
||||
if(key != nil){
|
||||
audioPlayers[key!]?.setVolume(args?[Constants.volume] as? Double,result)
|
||||
} else {
|
||||
result(FlutterError(code: Constants.audioWaveforms, message: "Can not set volume", details: "Player key is null"))
|
||||
}
|
||||
case Constants.setRate:
|
||||
let key = args?[Constants.playerKey] as? String
|
||||
if(key != nil){
|
||||
audioPlayers[key!]?.setRate(args?[Constants.rate] as? Double,result)
|
||||
} else {
|
||||
result(FlutterError(code: Constants.audioWaveforms, message: "Can not set rate", details: "Player key is null"))
|
||||
}
|
||||
case Constants.getDuration:
|
||||
let type = args?[Constants.durationType] as? Int
|
||||
let key = args?[Constants.playerKey] as? String
|
||||
if(key != nil){
|
||||
do{
|
||||
if(type == 0){
|
||||
try audioPlayers[key!]?.getDuration(DurationType.Current,result)
|
||||
} else {
|
||||
try audioPlayers[key!]?.getDuration( DurationType.Max,result)
|
||||
}
|
||||
} catch{
|
||||
result(FlutterError(code: "", message: "Failed to get duration", details: nil))
|
||||
}
|
||||
} else {
|
||||
result(FlutterError(code: Constants.audioWaveforms, message: "Can not get duration", details: "Player key is null"))
|
||||
}
|
||||
case Constants.stopAllPlayers:
|
||||
for (playerKey,_) in audioPlayers {
|
||||
audioPlayers[playerKey]?.stopPlayer()
|
||||
audioPlayers[playerKey] = nil
|
||||
}
|
||||
result(true)
|
||||
case Constants.extractWaveformData:
|
||||
guard let key = args?[Constants.playerKey] as? String else {
|
||||
result(
|
||||
FlutterError(
|
||||
code: Constants.audioWaveforms,
|
||||
message: "Can not get waveform data",
|
||||
details: "Waveform key is null"
|
||||
)
|
||||
)
|
||||
break
|
||||
}
|
||||
let path = args?[Constants.path] as? String
|
||||
let noOfSamples = args?[Constants.noOfSamples] as? Int
|
||||
createOrUpdateExtractor(
|
||||
playerKey: key,
|
||||
result: result,
|
||||
path: path,
|
||||
noOfSamples: noOfSamples
|
||||
)
|
||||
case Constants.stopExtraction:
|
||||
guard let key = args?[Constants.playerKey] as? String else {
|
||||
result(FlutterError(code: Constants.audioWaveforms, message: "Can not get waveform data", details: "Waveform key is null"))
|
||||
break
|
||||
}
|
||||
extractors[key]?.cancel()
|
||||
result(true)
|
||||
case Constants.pauseAllPlayers:
|
||||
for(playerKey,_) in audioPlayers {
|
||||
audioPlayers[playerKey]?.pausePlayer()
|
||||
}
|
||||
result(true)
|
||||
break
|
||||
default:
|
||||
result(FlutterMethodNotImplemented)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func initPlayer(playerKey: String) {
|
||||
if audioPlayers[playerKey] == nil {
|
||||
let newPlayer = AudioPlayer(plugin: self,playerKey: playerKey,channel: flutterChannel)
|
||||
audioPlayers[playerKey] = newPlayer
|
||||
}
|
||||
}
|
||||
|
||||
func createOrUpdateExtractor(playerKey: String, result: @escaping FlutterResult,path: String?, noOfSamples: Int?) {
|
||||
if(!(path ?? "").isEmpty) {
|
||||
do {
|
||||
let audioUrl = URL.init(string: path!)
|
||||
if(audioUrl == nil){
|
||||
result(FlutterError(code: Constants.audioWaveforms, message: "Failed to initialise Url from provided audio file", details: "If path contains `file://` try removing it"))
|
||||
return
|
||||
}
|
||||
extractors[playerKey]?.cancel()
|
||||
let newExtractor = try WaveformExtractor(url: audioUrl!, flutterResult: result, channel: flutterChannel)
|
||||
extractors[playerKey] = newExtractor
|
||||
Task {
|
||||
await newExtractor
|
||||
.extractWaveform(samplesPerPixel: noOfSamples, playerKey: playerKey,
|
||||
onExtractionComplete: { data in
|
||||
result(data)
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
result(FlutterError(code: Constants.audioWaveforms, message: "Failed to decode audio file", details: nil))
|
||||
}
|
||||
} else {
|
||||
result(FlutterError(code: Constants.audioWaveforms, message: "Audio file path can't be empty or null", details: nil))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
93
audio_waveforms/ios/Classes/Utils.swift
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
enum DurationType {
|
||||
case Current
|
||||
case Max
|
||||
}
|
||||
|
||||
struct Constants {
|
||||
static let methodChannelName = "simform_audio_waveforms_plugin/methods"
|
||||
static let audioWaveforms = "AudioWaveforms"
|
||||
static let startRecording = "startRecording"
|
||||
static let pauseRecording = "pauseRecording"
|
||||
static let stopRecording = "stopRecording"
|
||||
static let getDecibel = "getDecibel"
|
||||
static let checkPermission = "checkPermission"
|
||||
static let path = "path"
|
||||
static let encoder = "encoder"
|
||||
static let sampleRate = "sampleRate"
|
||||
static let bitRate = "bitRate"
|
||||
static let fileNameFormat = "YY-MM-dd-HH-mm-ss"
|
||||
static let resumeRecording = "resumeRecording"
|
||||
|
||||
static let kAudioFormatMPEG4AAC = 0
|
||||
static let kAudioFormatMPEGLayer1 = 1
|
||||
static let kAudioFormatMPEGLayer2 = 2
|
||||
static let kAudioFormatMPEGLayer3 = 3
|
||||
static let kAudioFormatMPEG4AAC_ELD = 4
|
||||
static let kAudioFormatMPEG4AAC_HE = 5
|
||||
static let kAudioFormatOpus = 6
|
||||
static let kAudioFormatAMR = 7
|
||||
static let kAudioFormatAMR_WB = 8
|
||||
static let kAudioFormatLinearPCM = 9
|
||||
static let kAudioFormatAppleLossless = 10
|
||||
static let kAudioFormatMPEG4AAC_HE_V2 = 11
|
||||
|
||||
static let readAudioFile = "readAudioFile"
|
||||
static let durationEventChannel = "durationEventChannel"
|
||||
static let startPlayer = "startPlayer"
|
||||
static let stopPlayer = "stopPlayer"
|
||||
static let pausePlayer = "pausePlayer"
|
||||
static let releasePlayer = "releasePlayer"
|
||||
static let seekTo = "seekTo"
|
||||
static let progress = "progress"
|
||||
static let setVolume = "setVolume"
|
||||
static let setRate = "setRate"
|
||||
static let rate = "rate"
|
||||
static let volume = "volume"
|
||||
static let finishMode = "finishMode"
|
||||
static let finishType = "finishType"
|
||||
static let getDuration = "getDuration"
|
||||
static let durationType = "durationType"
|
||||
static let preparePlayer = "preparePlayer"
|
||||
static let onCurrentDuration = "onCurrentDuration"
|
||||
static let current = "current"
|
||||
static let playerKey = "playerKey"
|
||||
static let stopAllPlayers = "stopAllPlayers"
|
||||
static let onDidFinishPlayingAudio = "onDidFinishPlayingAudio"
|
||||
static let extractWaveformData = "extractWaveformData"
|
||||
static let noOfSamples = "noOfSamples"
|
||||
static let onCurrentExtractedWaveformData = "onCurrentExtractedWaveformData"
|
||||
static let waveformData = "waveformData"
|
||||
static let onExtractionProgressUpdate = "onExtractionProgressUpdate"
|
||||
static let useLegacyNormalization = "useLegacyNormalization"
|
||||
static let updateFrequency = "updateFrequency"
|
||||
static let overrideAudioSession = "overrideAudioSession"
|
||||
static let resultFilePath = "resultFilePath"
|
||||
static let resultDuration = "resultDuration"
|
||||
static let linearPCMBitDepth = "linearPCMBitDepth";
|
||||
static let linearPCMIsBigEndian = "linearPCMIsBigEndian";
|
||||
static let linearPCMIsFloat = "linearPCMIsFloat";
|
||||
static let pauseAllPlayers = "pauseAllPlayers"
|
||||
static let stopExtraction = "stopExtraction"
|
||||
static let onAudioChunk = "onAudioChunk"
|
||||
static let bytes = "bytes"
|
||||
static let normalisedRms = "normalisedRms"
|
||||
static let recordedDuration = "recordedDuration"
|
||||
static let defaultSampleRate = 44100
|
||||
}
|
||||
|
||||
|
||||
/// Creates an 2D array of floats
|
||||
public typealias FloatChannelData = [[Float]]
|
||||
|
||||
/// Extension to fill array with zeros
|
||||
public extension RangeReplaceableCollection where Iterator.Element: ExpressibleByIntegerLiteral {
|
||||
init(zeros count: Int) {
|
||||
self.init(repeating: 0, count: count)
|
||||
}
|
||||
}
|
||||
|
||||
enum FinishMode : Int{
|
||||
case loop = 0
|
||||
case pause = 1
|
||||
case stop = 2
|
||||
}
|
||||
209
audio_waveforms/ios/Classes/WaveformExtractor.swift
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
import Accelerate
|
||||
import AVFoundation
|
||||
|
||||
public class WaveformExtractor {
|
||||
|
||||
public private(set) var audioFile: AVAudioFile?
|
||||
private var result: FlutterResult
|
||||
var flutterChannel: FlutterMethodChannel
|
||||
private var waveformData = Array<Float>()
|
||||
var progress: Float = 0.0
|
||||
var channelCount: Int = 1
|
||||
private var currentProgress: Float = 0.0
|
||||
private let abortWaveformDataQueue = DispatchQueue(
|
||||
label: "WaveformExtractor",
|
||||
attributes: .concurrent
|
||||
)
|
||||
|
||||
private var _abortGetWaveformData: Bool = false
|
||||
|
||||
public var abortGetWaveformData: Bool {
|
||||
get { _abortGetWaveformData }
|
||||
set {
|
||||
abortWaveformDataQueue.async(flags: .barrier) {
|
||||
self._abortGetWaveformData = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
public init(url: URL, flutterResult: @escaping FlutterResult, channel: FlutterMethodChannel) throws {
|
||||
result = flutterResult
|
||||
self.flutterChannel = channel
|
||||
do {
|
||||
audioFile = try AVAudioFile(forReading: url)
|
||||
} catch {
|
||||
audioFile = nil
|
||||
result(FlutterError(code: Constants.audioWaveforms, message: error.localizedDescription, details: "Couldn't initialise AVAudioFile from \(url.absoluteString)"))
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
audioFile = nil
|
||||
}
|
||||
|
||||
public func extractWaveform(
|
||||
samplesPerPixel: Int?,
|
||||
offset: Int? = 0,
|
||||
length: UInt? = nil,
|
||||
playerKey: String,
|
||||
onExtractionComplete: ([Float]?) -> Void
|
||||
) async -> Void {
|
||||
guard let audioFile = audioFile else { return }
|
||||
|
||||
/// Prevent division by zero, + minimum resolution
|
||||
let samplesPerPixel = max(1, samplesPerPixel ?? 100)
|
||||
let currentFrame = audioFile.framePosition
|
||||
let totalFrames = AVAudioFrameCount(audioFile.length)
|
||||
var framesPerBuffer = totalFrames / AVAudioFrameCount(samplesPerPixel)
|
||||
|
||||
guard let rmsBuffer = AVAudioPCMBuffer(
|
||||
pcmFormat: audioFile.processingFormat,
|
||||
frameCapacity: framesPerBuffer
|
||||
) else { return }
|
||||
|
||||
let channelCount = Int(audioFile.processingFormat.channelCount)
|
||||
let waveformStorage = WaveformStorage(
|
||||
channelCount: channelCount,
|
||||
size: samplesPerPixel
|
||||
)
|
||||
|
||||
let startIndex = max(
|
||||
0, offset ?? Int(currentFrame / Int64(framesPerBuffer))
|
||||
)
|
||||
let endIndex = min(
|
||||
samplesPerPixel, startIndex + (length.map { Int($0) } ?? samplesPerPixel)
|
||||
)
|
||||
|
||||
if startIndex > endIndex {
|
||||
sendErrorToFlutter(
|
||||
message: "Offset is larger than total length.",
|
||||
details: "Please select less number of samples"
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
var startFrame: AVAudioFramePosition = offset == nil
|
||||
? currentFrame
|
||||
: Int64(startIndex * Int(framesPerBuffer))
|
||||
|
||||
for i in startIndex..<endIndex {
|
||||
if abortGetWaveformData {
|
||||
audioFile.framePosition = currentFrame
|
||||
abortGetWaveformData = false
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
audioFile.framePosition = startFrame
|
||||
try audioFile.read(into: rmsBuffer, frameCount: framesPerBuffer)
|
||||
} catch {
|
||||
sendErrorToFlutter(
|
||||
message: "Couldn't read buffer. \(error.localizedDescription)"
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
guard let floatData = rmsBuffer.floatChannelData else { return }
|
||||
|
||||
for channel in 0..<channelCount {
|
||||
/// Calculating RMS(Root mean square)
|
||||
var rmsValue: Float = 0.0
|
||||
vDSP_rmsqv(
|
||||
floatData[channel], 1, &rmsValue,
|
||||
vDSP_Length(rmsBuffer.frameLength)
|
||||
)
|
||||
await waveformStorage.update(
|
||||
channel: channel, index: i, value: rmsValue
|
||||
)
|
||||
}
|
||||
|
||||
let progress = Float(i - startIndex + 1) / Float(endIndex - startIndex)
|
||||
await sendWaveformDataToFlutter(
|
||||
waveformStorage: waveformStorage,
|
||||
progress: progress,
|
||||
playerKey: playerKey
|
||||
)
|
||||
|
||||
startFrame += AVAudioFramePosition(framesPerBuffer)
|
||||
if startFrame + AVAudioFramePosition(framesPerBuffer) > totalFrames {
|
||||
framesPerBuffer = totalFrames - AVAudioFrameCount(startFrame)
|
||||
if framesPerBuffer <= 0 { break }
|
||||
}
|
||||
}
|
||||
|
||||
audioFile.framePosition = currentFrame
|
||||
let waveformData = await waveformStorage.getData()
|
||||
let data = getChannelMean(data: waveformData)
|
||||
onExtractionComplete(data);
|
||||
}
|
||||
|
||||
func getChannelMean(data: FloatChannelData) -> [Float] {
|
||||
var resultWaveform = [Float]()
|
||||
|
||||
if channelCount == 2, !data[0].isEmpty, !data[1].isEmpty {
|
||||
resultWaveform = zip(data[0], data[1]).map { ($0 + $1) / 2 }
|
||||
} else if !data[0].isEmpty {
|
||||
resultWaveform = data[0]
|
||||
} else if !data[1].isEmpty {
|
||||
resultWaveform = data[1]
|
||||
} else {
|
||||
sendErrorToFlutter(
|
||||
message: "Cannot get waveform mean",
|
||||
details: "Both audio channels are null"
|
||||
)
|
||||
}
|
||||
return resultWaveform
|
||||
}
|
||||
|
||||
public func cancel() {
|
||||
abortGetWaveformData = true
|
||||
}
|
||||
|
||||
private func sendWaveformDataToFlutter(
|
||||
waveformStorage: WaveformStorage,
|
||||
progress: Float,
|
||||
playerKey: String
|
||||
) async {
|
||||
let waveformData = await waveformStorage.getData()
|
||||
let meanData = getChannelMean(data: waveformData)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.flutterChannel.invokeMethod(
|
||||
Constants.onCurrentExtractedWaveformData,
|
||||
arguments: [
|
||||
Constants.waveformData: meanData,
|
||||
Constants.progress: progress,
|
||||
Constants.playerKey: playerKey
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func sendErrorToFlutter(message: String, details: String? = nil) {
|
||||
DispatchQueue.main.async {
|
||||
self.result(
|
||||
FlutterError(
|
||||
code: Constants.audioWaveforms,
|
||||
message: message,
|
||||
details: details
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
actor WaveformStorage {
|
||||
private var data: [[Float]]
|
||||
|
||||
init(channelCount: Int, size: Int) {
|
||||
data = Array(repeating: [Float](repeating: 0, count: size), count: channelCount)
|
||||
}
|
||||
|
||||
func update(channel: Int, index: Int, value: Float) {
|
||||
data[channel][index] = value
|
||||
}
|
||||
|
||||
func getData() -> [[Float]] {
|
||||
return data
|
||||
}
|
||||
}
|
||||
23
audio_waveforms/ios/audio_waveforms.podspec
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
#
|
||||
# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html.
|
||||
# Run `pod lib lint audio_waveforms.podspec` to validate before publishing.
|
||||
#
|
||||
Pod::Spec.new do |s|
|
||||
s.name = 'audio_waveforms'
|
||||
s.version = '0.0.1'
|
||||
s.summary = 'A new Flutter project.'
|
||||
s.description = <<-DESC
|
||||
A new Flutter project.
|
||||
DESC
|
||||
s.homepage = 'http://example.com'
|
||||
s.license = { :file => '../LICENSE' }
|
||||
s.author = { 'Your Company' => 'email@example.com' }
|
||||
s.source = { :path => '.' }
|
||||
s.source_files = 'Classes/**/*'
|
||||
s.dependency 'Flutter'
|
||||
s.platform = :ios, '8.0'
|
||||
|
||||
# Flutter.framework does not contain a i386 slice.
|
||||
s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' }
|
||||
s.swift_version = '5.0'
|
||||
end
|
||||
12
audio_waveforms/lib/audio_waveforms.dart
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
library;
|
||||
|
||||
export 'src/audio_file_waveforms.dart';
|
||||
export 'src/audio_waveforms.dart';
|
||||
export 'src/base/player_wave_style.dart';
|
||||
export 'src/base/utils.dart';
|
||||
export 'src/base/wave_style.dart';
|
||||
export 'src/controllers/player_controller.dart';
|
||||
export 'src/controllers/recorder_controller.dart';
|
||||
export 'src/models/android_encoder_settings.dart';
|
||||
export 'src/models/ios_encoder_setting.dart';
|
||||
export 'src/models/recorder_settings.dart';
|
||||
436
audio_waveforms/lib/src/audio_file_waveforms.dart
Normal file
|
|
@ -0,0 +1,436 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../audio_waveforms.dart';
|
||||
import 'base/wave_clipper.dart';
|
||||
import 'painters/player_wave_painter.dart';
|
||||
|
||||
class AudioFileWaveforms extends StatefulWidget {
|
||||
/// Generate waveforms from audio file. You play those audio file using
|
||||
/// [PlayerController].
|
||||
///
|
||||
/// When you play the audio file, waves change their color according to
|
||||
/// how much audio has been played and how much is left.
|
||||
///
|
||||
/// With seeking gesture enabled, playing audio can be seeked to
|
||||
/// any position using gestures.
|
||||
const AudioFileWaveforms({
|
||||
super.key,
|
||||
required this.size,
|
||||
required this.playerController,
|
||||
this.waveformData = const [],
|
||||
this.continuousWaveform = true,
|
||||
this.playerWaveStyle = const PlayerWaveStyle(),
|
||||
this.padding,
|
||||
this.margin,
|
||||
this.decoration,
|
||||
this.backgroundColor,
|
||||
this.animationDuration = const Duration(milliseconds: 500),
|
||||
this.animationCurve = Curves.easeIn,
|
||||
this.clipBehavior = Clip.none,
|
||||
this.waveformType = WaveformType.long,
|
||||
this.enableSeekGesture = true,
|
||||
this.onDragStart,
|
||||
this.onDragEnd,
|
||||
this.dragUpdateDetails,
|
||||
this.onTapUp,
|
||||
this.seekOnTapUp = true,
|
||||
this.onTapDown,
|
||||
});
|
||||
|
||||
/// A size to define height and width of waveform.
|
||||
final Size size;
|
||||
|
||||
/// A PlayerController having different controls for audio player.
|
||||
final PlayerController playerController;
|
||||
|
||||
/// Directly draws waveforms from this data. Extracted waveform data
|
||||
/// is ignored if waveform data is provided from this parameter.
|
||||
final List<double> waveformData;
|
||||
|
||||
/// When this flag is set to true, new waves are drawn as soon as new
|
||||
/// waveform data is available from [onCurrentExtractedWaveformData].
|
||||
/// If this flag is set to false then waveforms will be drawn after waveform
|
||||
/// extraction is fully completed.
|
||||
///
|
||||
/// This flag is ignored if [waveformData] is directly provided.
|
||||
///
|
||||
/// See documentation of extractWaveformData in [PlayerController] to
|
||||
/// determine which value to choose.
|
||||
///
|
||||
/// Defaults to true.
|
||||
final bool continuousWaveform;
|
||||
|
||||
/// A PlayerWaveStyle instance controls how waveforms should look.
|
||||
final PlayerWaveStyle playerWaveStyle;
|
||||
|
||||
/// Provides padding around waveform.
|
||||
final EdgeInsets? padding;
|
||||
|
||||
/// Provides margin around waveform.
|
||||
final EdgeInsets? margin;
|
||||
|
||||
/// Provides box decoration to the container having waveforms.
|
||||
final BoxDecoration? decoration;
|
||||
|
||||
/// Color which is applied in to background of the waveform.
|
||||
/// If decoration is used then use color in it.
|
||||
final Color? backgroundColor;
|
||||
|
||||
/// Duration for animation. Defaults to 500 milliseconds.
|
||||
final Duration animationDuration;
|
||||
|
||||
/// Curve for animation. Defaults to Curves.easeIn
|
||||
final Curve animationCurve;
|
||||
|
||||
/// A clipping behaviour which is applied to container having waveforms.
|
||||
final Clip clipBehavior;
|
||||
|
||||
/// Draws waveform bases on selected option. For more info, see
|
||||
/// [WaveformType] documentation.
|
||||
final WaveformType waveformType;
|
||||
|
||||
/// Allow seeking with gestures when turned on.
|
||||
final bool enableSeekGesture;
|
||||
|
||||
/// Provides a callback when drag starts.
|
||||
final ValueSetter<DragStartDetails>? onDragStart;
|
||||
|
||||
/// Provides a callback when drag ends.
|
||||
final ValueSetter<DragEndDetails>? onDragEnd;
|
||||
|
||||
/// Provides a callback on drag updates.
|
||||
final ValueSetter<DragUpdateDetails>? dragUpdateDetails;
|
||||
|
||||
/// Provides a callback when pointer has stopped contacting the screen.
|
||||
/// This handler will still provide callback when [seekOnTapUp] is set to `false`.
|
||||
final ValueSetter<TapUpDetails>? onTapUp;
|
||||
|
||||
/// When set to true, seek gesture will be performed when pointer is lifted
|
||||
/// from the screen otherwise seek gesture will be performed when pointer has
|
||||
/// stopped contacting the screen.
|
||||
///
|
||||
/// The continuous seek gesture aren't affected by this flag.
|
||||
final bool seekOnTapUp;
|
||||
|
||||
/// Provides a callback when pointer has started contacting the screen.
|
||||
/// This handler will still provide callback when [seekOnTapUp] is set to `true`.
|
||||
final GestureTapDownCallback? onTapDown;
|
||||
|
||||
@override
|
||||
State<AudioFileWaveforms> createState() => _AudioFileWaveformsState();
|
||||
}
|
||||
|
||||
class _AudioFileWaveformsState extends State<AudioFileWaveforms>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _growingWaveController;
|
||||
late Animation<double> _growAnimation;
|
||||
|
||||
double _growAnimationProgress = 0.0;
|
||||
final ValueNotifier<int> _seekProgress = ValueNotifier(0);
|
||||
bool showSeekLine = false;
|
||||
|
||||
late EdgeInsets? margin;
|
||||
late EdgeInsets? padding;
|
||||
late BoxDecoration? decoration;
|
||||
late Color? backgroundColor;
|
||||
late Duration? animationDuration;
|
||||
late Curve? animationCurve;
|
||||
late Clip? clipBehavior;
|
||||
late StreamSubscription<int> onCurrentDurationSubscription;
|
||||
late StreamSubscription<void> onCompletionSubscription;
|
||||
StreamSubscription<List<double>>? onCurrentExtractedWaveformData;
|
||||
|
||||
double get spacing => widget.playerWaveStyle.spacing;
|
||||
|
||||
double get totalWaveWidth =>
|
||||
widget.playerWaveStyle.spacing * _waveformData.length;
|
||||
|
||||
PlayerWaveStyle get playerWaveStyle => widget.playerWaveStyle;
|
||||
|
||||
PlayerController get playerController => widget.playerController;
|
||||
|
||||
WaveformExtractionController get waveformExtraction =>
|
||||
playerController.waveformExtraction;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initialiseVariables();
|
||||
_growingWaveController = AnimationController(
|
||||
vsync: this,
|
||||
duration: widget.animationDuration,
|
||||
);
|
||||
_growAnimation = CurvedAnimation(
|
||||
parent: _growingWaveController,
|
||||
curve: widget.animationCurve,
|
||||
);
|
||||
|
||||
_growingWaveController
|
||||
..addListener(_updateGrowAnimationProgress)
|
||||
..forward();
|
||||
|
||||
onCurrentDurationSubscription =
|
||||
playerController.onCurrentDurationChanged.listen((event) {
|
||||
_seekProgress.value = event;
|
||||
_updatePlayerPercent();
|
||||
});
|
||||
|
||||
onCompletionSubscription = playerController.onCompletion.listen((event) {
|
||||
_seekProgress.value = playerController.maxDuration;
|
||||
_updatePlayerPercent();
|
||||
});
|
||||
if (widget.waveformData.isNotEmpty) {
|
||||
_addWaveformData(widget.waveformData);
|
||||
} else {
|
||||
if (waveformExtraction.waveformData.isNotEmpty) {
|
||||
_addWaveformData(waveformExtraction.waveformData);
|
||||
}
|
||||
if (!widget.continuousWaveform) {
|
||||
playerController.addListener(_addWaveformDataFromController);
|
||||
} else {
|
||||
onCurrentExtractedWaveformData = waveformExtraction
|
||||
.onCurrentExtractedWaveformData
|
||||
.listen(_addWaveformData);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
onCurrentDurationSubscription.cancel();
|
||||
onCurrentExtractedWaveformData?.cancel();
|
||||
onCompletionSubscription.cancel();
|
||||
playerController.removeListener(_addWaveformDataFromController);
|
||||
_growingWaveController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
double _audioProgress = 0.0;
|
||||
double _cachedAudioProgress = 0.0;
|
||||
|
||||
Offset _totalBackDistance = Offset.zero;
|
||||
Offset _dragOffset = Offset.zero;
|
||||
|
||||
double _initialDragPosition = 0.0;
|
||||
double _scrollDirection = 0.0;
|
||||
|
||||
bool _isScrolled = false;
|
||||
double scrollScale = 1.0;
|
||||
double _proportion = 0.0;
|
||||
|
||||
final List<double> _waveformData = [];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: widget.padding,
|
||||
margin: widget.margin,
|
||||
decoration: widget.decoration,
|
||||
clipBehavior: widget.clipBehavior,
|
||||
child: GestureDetector(
|
||||
onHorizontalDragUpdate:
|
||||
widget.enableSeekGesture ? _handleDragGestures : null,
|
||||
onTapUp: widget.enableSeekGesture ? _handleOnTapUp : null,
|
||||
onHorizontalDragStart:
|
||||
widget.enableSeekGesture ? _handleHorizontalDragStart : null,
|
||||
onHorizontalDragEnd: widget.enableSeekGesture ? _handleOnDragEnd : null,
|
||||
onTapDown: widget.enableSeekGesture ? _handleOnTapDown : null,
|
||||
child: ClipPath(
|
||||
// TODO: Update extraClipperHeight when duration labels are added
|
||||
clipper: WaveClipper(extraClipperHeight: 0),
|
||||
child: RepaintBoundary(
|
||||
child: ValueListenableBuilder<int>(
|
||||
builder: (_, __, ___) {
|
||||
return CustomPaint(
|
||||
isComplex: true,
|
||||
painter: PlayerWavePainter(
|
||||
playerWaveStyle: playerWaveStyle,
|
||||
waveformData: _waveformData,
|
||||
animValue: _growAnimationProgress,
|
||||
totalBackDistance: _totalBackDistance,
|
||||
dragOffset: _dragOffset,
|
||||
audioProgress: _audioProgress,
|
||||
callPushback: !_isScrolled,
|
||||
pushBack: _pushBackWave,
|
||||
scrollScale: scrollScale,
|
||||
waveformType: widget.waveformType,
|
||||
cachedAudioProgress: _cachedAudioProgress,
|
||||
),
|
||||
size: widget.size,
|
||||
);
|
||||
},
|
||||
valueListenable: _seekProgress,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _addWaveformDataFromController() =>
|
||||
_addWaveformData(waveformExtraction.waveformData);
|
||||
|
||||
void _updateGrowAnimationProgress() {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_growAnimationProgress = _growAnimation.value;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _handleOnDragEnd(DragEndDetails dragEndDetails) {
|
||||
_isScrolled = false;
|
||||
scrollScale = 1.0;
|
||||
if (mounted) setState(() {});
|
||||
|
||||
if (widget.waveformType.isLong) {
|
||||
playerController.seekTo(
|
||||
(playerController.maxDuration * _proportion).toInt(),
|
||||
);
|
||||
}
|
||||
widget.onDragEnd?.call(dragEndDetails);
|
||||
}
|
||||
|
||||
void _addWaveformData(List<double> data) {
|
||||
_waveformData
|
||||
..clear()
|
||||
..addAll(data);
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
void _handleDragGestures(DragUpdateDetails details) {
|
||||
switch (widget.waveformType) {
|
||||
case WaveformType.fitWidth:
|
||||
_handleScrubberSeekUpdate(details);
|
||||
break;
|
||||
case WaveformType.long:
|
||||
_handleScrollUpdate(details);
|
||||
break;
|
||||
}
|
||||
|
||||
widget.dragUpdateDetails?.call(details);
|
||||
}
|
||||
|
||||
/// This method handles continues seek gesture
|
||||
void _handleScrubberSeekUpdate(DragUpdateDetails details) {
|
||||
final localPosition = details.localPosition.dx;
|
||||
|
||||
_proportion = localPosition <= 0 ? 0 : localPosition / widget.size.width;
|
||||
var seekPosition = playerController.maxDuration * _proportion;
|
||||
|
||||
playerController.seekTo(seekPosition.toInt());
|
||||
}
|
||||
|
||||
/// This method handles tap seek gesture
|
||||
void _handleOnTapUp(TapUpDetails details) {
|
||||
widget.onTapUp?.call(details);
|
||||
if (!widget.seekOnTapUp) return;
|
||||
_proportion = details.localPosition.dx / widget.size.width;
|
||||
var seekPosition = playerController.maxDuration * _proportion;
|
||||
|
||||
playerController.seekTo(seekPosition.toInt());
|
||||
}
|
||||
|
||||
/// This method handles tap seek gesture
|
||||
void _handleOnTapDown(TapDownDetails details) {
|
||||
widget.onTapDown?.call(details);
|
||||
if (widget.seekOnTapUp) return;
|
||||
_proportion = details.localPosition.dx / widget.size.width;
|
||||
var seekPosition = playerController.maxDuration * _proportion;
|
||||
|
||||
playerController.seekTo(seekPosition.toInt());
|
||||
}
|
||||
|
||||
///This method handles horizontal scrolling of the wave
|
||||
void _handleScrollUpdate(DragUpdateDetails details) {
|
||||
// Direction of the scroll. Negative value indicates scroll left to right
|
||||
// and positive value indicates scroll right to left
|
||||
_scrollDirection = details.localPosition.dx - _initialDragPosition;
|
||||
playerController.setRefresh(false);
|
||||
_isScrolled = true;
|
||||
|
||||
scrollScale = playerWaveStyle.scrollScale;
|
||||
|
||||
final spacing = playerWaveStyle.spacing;
|
||||
|
||||
// Update the drag offset based on scroll direction and thresholds.
|
||||
final currentPosition = -_totalBackDistance.dx + _dragOffset.dx;
|
||||
final updatedPosition = currentPosition + details.delta.dx;
|
||||
|
||||
// left to right
|
||||
if (updatedPosition + (spacing) < spacing / 2 && _scrollDirection > 0) {
|
||||
_dragOffset += details.delta;
|
||||
}
|
||||
|
||||
// right to left
|
||||
else if (currentPosition + totalWaveWidth + details.delta.dx >
|
||||
(-spacing / 2) &&
|
||||
_scrollDirection < 0) {
|
||||
_dragOffset += details.delta;
|
||||
}
|
||||
|
||||
// Indicates location of first wave
|
||||
var start = currentPosition - (spacing / 2);
|
||||
|
||||
_proportion = _scrollDirection < 0
|
||||
? (start.abs() + details.delta.dx) / totalWaveWidth
|
||||
: (details.delta.dx - start - spacing) / totalWaveWidth;
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
///This will help-out to determine direction of the scroll
|
||||
void _handleHorizontalDragStart(DragStartDetails details) {
|
||||
_initialDragPosition = details.localPosition.dx;
|
||||
widget.onDragStart?.call(details);
|
||||
}
|
||||
|
||||
/// This initialises variable in [initState] so that everytime current duration
|
||||
/// gets updated it doesn't re assign them to same values.
|
||||
void _initialiseVariables() {
|
||||
if (waveformExtraction.waveformData.isEmpty) {
|
||||
waveformExtraction.waveformData.addAll(widget.waveformData);
|
||||
}
|
||||
showSeekLine = false;
|
||||
margin = widget.margin;
|
||||
padding = widget.padding;
|
||||
decoration = widget.decoration;
|
||||
backgroundColor = widget.backgroundColor;
|
||||
animationDuration = widget.animationDuration;
|
||||
animationCurve = widget.animationCurve;
|
||||
clipBehavior = widget.clipBehavior;
|
||||
}
|
||||
|
||||
/// calculates seek progress
|
||||
void _updatePlayerPercent() {
|
||||
if (playerController.maxDuration == 0) return;
|
||||
_audioProgress = _seekProgress.value / playerController.maxDuration;
|
||||
}
|
||||
|
||||
///This will handle pushing back the wave when it reaches to middle/end of the
|
||||
///given size.width.
|
||||
///
|
||||
///This will also handle refreshing the wave after scrolled
|
||||
void _pushBackWave() {
|
||||
if (!_isScrolled && widget.waveformType.isLong) {
|
||||
_totalBackDistance = Offset(
|
||||
(playerWaveStyle.spacing * _audioProgress * _waveformData.length) +
|
||||
playerWaveStyle.spacing +
|
||||
_dragOffset.dx,
|
||||
0.0,
|
||||
);
|
||||
}
|
||||
if (playerController.shouldClearLabels) {
|
||||
_initialDragPosition = 0.0;
|
||||
_totalBackDistance = Offset.zero;
|
||||
_dragOffset = Offset.zero;
|
||||
}
|
||||
_cachedAudioProgress = _audioProgress;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
309
audio_waveforms/lib/src/audio_waveforms.dart
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '/audio_waveforms.dart';
|
||||
import 'base/label.dart';
|
||||
import 'base/wave_clipper.dart';
|
||||
import 'painters/recorder_wave_painter.dart';
|
||||
|
||||
class AudioWaveforms extends StatefulWidget {
|
||||
const AudioWaveforms({
|
||||
super.key,
|
||||
required this.size,
|
||||
required this.recorderController,
|
||||
this.waveStyle = const WaveStyle(),
|
||||
this.enableGesture = false,
|
||||
this.padding,
|
||||
this.margin,
|
||||
this.decoration,
|
||||
this.backgroundColor,
|
||||
this.shouldCalculateScrolledPosition = false,
|
||||
});
|
||||
|
||||
final Size size;
|
||||
final RecorderController recorderController;
|
||||
final WaveStyle waveStyle;
|
||||
final EdgeInsets? padding;
|
||||
final EdgeInsets? margin;
|
||||
final BoxDecoration? decoration;
|
||||
final Color? backgroundColor;
|
||||
final bool enableGesture;
|
||||
final bool shouldCalculateScrolledPosition;
|
||||
|
||||
@override
|
||||
State<AudioWaveforms> createState() => _AudioWaveformsState();
|
||||
}
|
||||
|
||||
class _AudioWaveformsState extends State<AudioWaveforms> {
|
||||
bool _isScrolled = false;
|
||||
|
||||
/// Tracks the total horizontal offset applied when the waveform is shifted backward.
|
||||
Offset _totalBackDistance = Offset.zero;
|
||||
Offset _dragOffset = Offset.zero;
|
||||
|
||||
double _initialOffsetPosition = 0.0;
|
||||
late double _initialPosition;
|
||||
Duration currentlyRecordedDuration = Duration.zero;
|
||||
late StreamSubscription<Duration> streamSubscription;
|
||||
|
||||
late final Size _size;
|
||||
late final WaveStyle _waveStyle;
|
||||
late final RecorderController _recorderController;
|
||||
late final bool _isRtl = widget.waveStyle.waveformRenderMode.isRtl;
|
||||
|
||||
/// Duration timestamp labels shown on the waveform, added every second during recording.
|
||||
final List<Label> _labels = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_size = widget.size;
|
||||
_waveStyle = widget.waveStyle;
|
||||
_recorderController = widget.recorderController;
|
||||
// For RTL, initial position starts at 0 (waves grow from right edge)
|
||||
// For LTR, initial position starts at negative half thickness
|
||||
_initialPosition = _isRtl ? 0.0 : -(_waveStyle.waveThickness / 2);
|
||||
_recorderController.addListener(_recorderControllerListener);
|
||||
streamSubscription =
|
||||
_recorderController.onCurrentDuration.listen((duration) {
|
||||
currentlyRecordedDuration = duration;
|
||||
final currentSeconds = currentlyRecordedDuration.inSeconds;
|
||||
if (currentSeconds > 0 && _labels.length < currentSeconds) {
|
||||
_labels.add(
|
||||
Label(
|
||||
content: _waveStyle.showHourInDuration
|
||||
? Duration(seconds: currentSeconds).toHHMMSS()
|
||||
: currentSeconds.toMMSS(),
|
||||
// Calculate label position based on current waveform length
|
||||
// X-axis: Position label at the end of the waveform
|
||||
// (spacing × number of wave bars = total waveform width)
|
||||
// Y-axis: Position below the waveform container
|
||||
// (container height + line height = below the waveform)
|
||||
offset: Offset(
|
||||
_waveStyle.spacing * _recorderController.waveData.length,
|
||||
_size.height + _waveStyle.durationLinesHeight,
|
||||
),
|
||||
),
|
||||
);
|
||||
// Only trigger UI rebuild if widget is still in the tree
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_recorderController.removeListener(_recorderControllerListener);
|
||||
streamSubscription.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: widget.padding,
|
||||
margin: widget.margin,
|
||||
color: widget.backgroundColor,
|
||||
decoration: widget.decoration,
|
||||
child: GestureDetector(
|
||||
onHorizontalDragUpdate:
|
||||
widget.enableGesture ? _handleHorizontalDragUpdate : null,
|
||||
onHorizontalDragStart:
|
||||
widget.enableGesture ? _handleHorizontalDragStart : null,
|
||||
child: ClipPath(
|
||||
clipper: WaveClipper(
|
||||
extraClipperHeight: _extraClipperHeight,
|
||||
waveWidth: _waveWidth,
|
||||
),
|
||||
child: RepaintBoundary(
|
||||
child: CustomPaint(
|
||||
size: _size,
|
||||
painter: RecorderWavePainter(
|
||||
labels: _labels,
|
||||
waveThickness: _waveStyle.waveThickness,
|
||||
middleLineThickness: _waveStyle.middleLineThickness,
|
||||
middleLineColor: _waveStyle.middleLineColor,
|
||||
waveData: _recorderController.waveData,
|
||||
callPushback: _recorderController.shouldRefresh,
|
||||
bottomPadding: _waveStyle.bottomPadding ?? _size.height / 2,
|
||||
spacing: _waveStyle.spacing,
|
||||
waveCap: _waveStyle.waveCap,
|
||||
showBottom: _waveStyle.showBottom,
|
||||
showTop: _waveStyle.showTop,
|
||||
waveColor: _waveStyle.waveColor,
|
||||
showMiddleLine: _waveStyle.showMiddleLine,
|
||||
totalCurrentBackDistance: _totalBackDistance,
|
||||
dragOffset: _dragOffset,
|
||||
pushBack: _pushBackWave,
|
||||
initialPosition: _initialPosition,
|
||||
extendWaveform: _waveStyle.extendWaveform,
|
||||
showHourInDuration: _waveStyle.showHourInDuration,
|
||||
showDurationLabel: _waveStyle.showDurationLabel,
|
||||
durationLinesColor: _waveStyle.durationLinesColor,
|
||||
durationStyle: _waveStyle.durationStyle,
|
||||
durationTextPadding: _waveStyle.durationTextPadding,
|
||||
durationLinesHeight: _waveStyle.durationLinesHeight,
|
||||
labelSpacing: _waveStyle.labelSpacing,
|
||||
gradient: _waveStyle.gradient,
|
||||
shouldClearLabels: _recorderController.shouldClearLabels,
|
||||
revertClearLabelCall: _recorderController.revertClearLabelCall,
|
||||
setCurrentPositionDuration:
|
||||
_recorderController.setScrolledPositionDuration,
|
||||
shouldCalculateScrolledPosition:
|
||||
widget.shouldCalculateScrolledPosition,
|
||||
scaleFactor: _waveStyle.scaleFactor,
|
||||
currentlyRecordedDuration: currentlyRecordedDuration,
|
||||
isRtl: _isRtl,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Gets width of a single wave including space between two waves.
|
||||
double get _waveWidth => _waveStyle.waveThickness + _waveStyle.spacing;
|
||||
|
||||
/// Provides extra clipping if needed.
|
||||
double get _extraClipperHeight {
|
||||
if (_waveStyle.showDurationLabel) {
|
||||
// If duration labels are enabled and for some reason labels are getting
|
||||
// cut or effecting other widget cut. This will help to reduce or add
|
||||
// clipping.
|
||||
if (_waveStyle.extraClipperHeight != null) {
|
||||
return _waveStyle.extraClipperHeight!;
|
||||
}
|
||||
// Default clipping. Calculated from duration line.
|
||||
return _waveStyle.durationLinesHeight +
|
||||
(_waveStyle.durationStyle.fontSize ?? _waveStyle.durationLinesHeight);
|
||||
} else {
|
||||
// If labels are disabled then there is no need to add/remove extra
|
||||
// clipping.
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
///This handles scrolling of the wave
|
||||
void _handleHorizontalDragUpdate(DragUpdateDetails details) {
|
||||
_recorderController.setRefresh(false);
|
||||
_isScrolled = true;
|
||||
|
||||
switch (_waveStyle.waveformRenderMode) {
|
||||
case WaveformRenderMode.ltr:
|
||||
_handleScrollLtr(details);
|
||||
case WaveformRenderMode.rtl:
|
||||
_handleScrollRtl(details);
|
||||
}
|
||||
}
|
||||
|
||||
///This will help-out to determine to get direction of the scroll
|
||||
void _handleHorizontalDragStart(DragStartDetails details) {
|
||||
_initialOffsetPosition = details.globalPosition.dx;
|
||||
}
|
||||
|
||||
///This will handle pushing back the wave when it reaches to middle/end of the
|
||||
///given size.width.
|
||||
///
|
||||
///This will also handle refreshing the wave after scrolled
|
||||
void _pushBackWave() {
|
||||
if (_isRtl) {
|
||||
if (!_isScrolled) {
|
||||
_totalBackDistance =
|
||||
_totalBackDistance + Offset(_waveStyle.spacing, 0.0);
|
||||
}
|
||||
|
||||
// For RTL: handle refresh after scrolling
|
||||
if (_recorderController.shouldRefresh && _isScrolled) {
|
||||
_initialOffsetPosition = 0.0;
|
||||
_dragOffset = Offset.zero;
|
||||
_isScrolled = false;
|
||||
// Reset shouldRefresh flag and trigger rebuild with new values
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) {
|
||||
_recorderController.setRefresh(false);
|
||||
},
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (_isScrolled) {
|
||||
_initialPosition =
|
||||
_waveStyle.spacing * _recorderController.waveData.length -
|
||||
_size.width / 2;
|
||||
_totalBackDistance =
|
||||
_totalBackDistance + Offset(_waveStyle.spacing, 0.0);
|
||||
_isScrolled = false;
|
||||
} else {
|
||||
_initialPosition = 0.0;
|
||||
_totalBackDistance =
|
||||
_totalBackDistance + Offset(_waveStyle.spacing, 0.0);
|
||||
}
|
||||
}
|
||||
if (_recorderController.shouldClearLabels) {
|
||||
_initialOffsetPosition = 0.0;
|
||||
_totalBackDistance = Offset.zero;
|
||||
_dragOffset = Offset.zero;
|
||||
}
|
||||
}
|
||||
|
||||
void _recorderControllerListener() {
|
||||
if (!mounted) return;
|
||||
|
||||
// Only call setState if labels actually need to be cleared
|
||||
setState(() {
|
||||
if (_recorderController.shouldClearLabels) {
|
||||
_labels.clear();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Handles scrolling for LTR waveform
|
||||
void _handleScrollLtr(DragUpdateDetails details) {
|
||||
var direction = details.globalPosition.dx - _initialOffsetPosition;
|
||||
final delta = details.delta;
|
||||
final deltaDx = details.delta.dx;
|
||||
final dragOffset = _dragOffset.dx;
|
||||
final totalBackDistanceDx = -_totalBackDistance.dx;
|
||||
final halfWidth = _size.width / 2;
|
||||
final waveformWidth =
|
||||
_waveStyle.spacing * _recorderController.waveData.length;
|
||||
|
||||
///left to right
|
||||
if (totalBackDistanceDx + dragOffset + deltaDx < halfWidth &&
|
||||
direction > 0) {
|
||||
setState(() => _dragOffset += delta);
|
||||
}
|
||||
|
||||
///right to left
|
||||
else if (totalBackDistanceDx + dragOffset + waveformWidth + deltaDx >
|
||||
halfWidth &&
|
||||
direction < 0) {
|
||||
setState(() => _dragOffset += delta);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles scrolling for RTL waveform
|
||||
void _handleScrollRtl(DragUpdateDetails details) {
|
||||
var direction = details.globalPosition.dx - _initialOffsetPosition;
|
||||
final delta = details.delta;
|
||||
final dragOffsetDx = _dragOffset.dx;
|
||||
|
||||
final waveformWidth =
|
||||
_waveStyle.spacing * _recorderController.waveData.length;
|
||||
|
||||
final halfWidth = _size.width / 2;
|
||||
|
||||
/// right to left
|
||||
if (direction < 0 && dragOffsetDx > -halfWidth) {
|
||||
setState(() => _dragOffset += delta);
|
||||
}
|
||||
|
||||
/// left to right
|
||||
else if (direction > 0 && dragOffsetDx < waveformWidth - halfWidth) {
|
||||
setState(() => _dragOffset += delta);
|
||||
}
|
||||
}
|
||||
}
|
||||
273
audio_waveforms/lib/src/base/audio_waveforms_interface.dart
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
part of '../controllers/player_controller.dart';
|
||||
|
||||
class AudioWaveformsInterface {
|
||||
AudioWaveformsInterface._();
|
||||
|
||||
static AudioWaveformsInterface instance = AudioWaveformsInterface._();
|
||||
|
||||
static const MethodChannel _methodChannel =
|
||||
MethodChannel(Constants.methodChannelName);
|
||||
|
||||
///platform call to start recording
|
||||
Future<bool> record({
|
||||
required RecorderSettings recorderSetting,
|
||||
String? path,
|
||||
bool overrideAudioSession = true,
|
||||
}) async {
|
||||
final isRecording = await _methodChannel.invokeMethod(
|
||||
Constants.startRecording,
|
||||
Platform.isIOS
|
||||
? recorderSetting.iosToJson(
|
||||
path: path,
|
||||
overrideAudioSession: overrideAudioSession,
|
||||
)
|
||||
: null,
|
||||
);
|
||||
return isRecording ?? false;
|
||||
}
|
||||
|
||||
/// Platform call to initialise the recorder.
|
||||
/// This method is only required for Android platform.
|
||||
Future<bool> initRecorder({
|
||||
String? path,
|
||||
required RecorderSettings recorderSettings,
|
||||
}) async {
|
||||
final initialized = await _methodChannel.invokeMethod(
|
||||
Constants.initRecorder,
|
||||
recorderSettings.androidToJson(path: path),
|
||||
);
|
||||
return initialized ?? false;
|
||||
}
|
||||
|
||||
///platform call to pause recording
|
||||
Future<bool?> pause() async {
|
||||
final isRecording =
|
||||
await _methodChannel.invokeMethod(Constants.pauseRecording);
|
||||
return isRecording;
|
||||
}
|
||||
|
||||
///platform call to stop recording
|
||||
Future<Map<String, dynamic>> stop() async {
|
||||
Map<Object?, Object?> audioInfo =
|
||||
await _methodChannel.invokeMethod(Constants.stopRecording);
|
||||
return audioInfo.cast<String, dynamic>();
|
||||
}
|
||||
|
||||
///platform call to resume recording.
|
||||
///This method is only required for Android platform
|
||||
Future<bool> resume() async {
|
||||
final isRecording =
|
||||
await _methodChannel.invokeMethod(Constants.resumeRecording);
|
||||
return isRecording ?? false;
|
||||
}
|
||||
|
||||
///platform call to get decibel
|
||||
Future<double?> getDecibel() async {
|
||||
var db = await _methodChannel.invokeMethod(Constants.getDecibel);
|
||||
return db;
|
||||
}
|
||||
|
||||
///platform call to check microphone permission
|
||||
Future<bool> checkPermission() async {
|
||||
var hasPermission =
|
||||
await _methodChannel.invokeMethod(Constants.checkPermission);
|
||||
return hasPermission ?? false;
|
||||
}
|
||||
|
||||
///platform call to prepare player
|
||||
Future<bool> preparePlayer({
|
||||
required String path,
|
||||
required String key,
|
||||
required int frequency,
|
||||
double? volume,
|
||||
bool overrideAudioSession = false,
|
||||
}) async {
|
||||
var result = await _methodChannel.invokeMethod(Constants.preparePlayer, {
|
||||
Constants.path: path,
|
||||
Constants.volume: volume,
|
||||
Constants.playerKey: key,
|
||||
Constants.updateFrequency: frequency,
|
||||
Constants.overrideAudioSession: overrideAudioSession,
|
||||
});
|
||||
return result ?? false;
|
||||
}
|
||||
|
||||
///platform call to start player
|
||||
Future<bool> startPlayer(String key) async {
|
||||
var result = await _methodChannel.invokeMethod(Constants.startPlayer, {
|
||||
Constants.playerKey: key,
|
||||
});
|
||||
return result ?? false;
|
||||
}
|
||||
|
||||
///platform call to stop player
|
||||
Future<bool> stopPlayer(String key) async {
|
||||
var result = await _methodChannel.invokeMethod(Constants.stopPlayer, {
|
||||
Constants.playerKey: key,
|
||||
});
|
||||
return result ?? false;
|
||||
}
|
||||
|
||||
///platform call to release resource
|
||||
Future<bool> release(String key) async {
|
||||
var result = await _methodChannel.invokeMethod(Constants.releasePlayer, {
|
||||
Constants.playerKey: key,
|
||||
});
|
||||
return result ?? false;
|
||||
}
|
||||
|
||||
///platform call to pause player
|
||||
Future<bool> pausePlayer(String key) async {
|
||||
var result = await _methodChannel.invokeMethod(Constants.pausePlayer, {
|
||||
Constants.playerKey: key,
|
||||
});
|
||||
return result ?? false;
|
||||
}
|
||||
|
||||
///platform call to get duration max/current
|
||||
Future<int?> getDuration(String key, int durationType) async {
|
||||
var duration = await _methodChannel.invokeMethod(Constants.getDuration, {
|
||||
Constants.durationType: durationType,
|
||||
Constants.playerKey: key,
|
||||
});
|
||||
return duration;
|
||||
}
|
||||
|
||||
///platform call to set volume
|
||||
Future<bool> setVolume(double volume, String key) async {
|
||||
var result = await _methodChannel.invokeMethod(Constants.setVolume, {
|
||||
Constants.volume: volume,
|
||||
Constants.playerKey: key,
|
||||
});
|
||||
return result ?? false;
|
||||
}
|
||||
|
||||
///platform call to set rate
|
||||
Future<bool> setRate(double rate, String key) async {
|
||||
var result = await _methodChannel.invokeMethod(Constants.setRate, {
|
||||
Constants.rate: rate,
|
||||
Constants.playerKey: key,
|
||||
});
|
||||
return result ?? false;
|
||||
}
|
||||
|
||||
///platform call to seek audio at provided position
|
||||
Future<bool> seekTo(String key, int progress) async {
|
||||
var result = await _methodChannel.invokeMethod(
|
||||
Constants.seekTo,
|
||||
{
|
||||
Constants.progress: progress,
|
||||
Constants.playerKey: key,
|
||||
},
|
||||
);
|
||||
return result ?? false;
|
||||
}
|
||||
|
||||
/// Sets the release mode.
|
||||
Future<void> setReleaseMode(String key, FinishMode finishMode) async {
|
||||
return await _methodChannel.invokeMethod(Constants.finishMode, {
|
||||
Constants.finishType: finishMode.index,
|
||||
Constants.playerKey: key,
|
||||
});
|
||||
}
|
||||
|
||||
Future<List<double>> extractWaveformData({
|
||||
required String key,
|
||||
required String path,
|
||||
required int noOfSamples,
|
||||
}) async {
|
||||
final result =
|
||||
await _methodChannel.invokeMethod(Constants.extractWaveformData, {
|
||||
Constants.playerKey: key,
|
||||
Constants.path: path,
|
||||
Constants.noOfSamples: noOfSamples,
|
||||
});
|
||||
return List<double>.from(result ?? []);
|
||||
}
|
||||
|
||||
/// Stops current executing waveform extraction, if any.
|
||||
Future<void> stopWaveformExtraction(String key) async {
|
||||
return await _methodChannel.invokeMethod(Constants.stopExtraction, {
|
||||
Constants.playerKey: key,
|
||||
});
|
||||
}
|
||||
|
||||
Future<bool> stopAllPlayers() async {
|
||||
var result = await _methodChannel.invokeMethod(Constants.stopAllPlayers);
|
||||
return result ?? false;
|
||||
}
|
||||
|
||||
Future<bool> pauseAllPlayers() async {
|
||||
var result = await _methodChannel.invokeMethod(Constants.pauseAllPlayers);
|
||||
return result ?? false;
|
||||
}
|
||||
|
||||
Future<void> setMethodCallHandler() async {
|
||||
_methodChannel.setMethodCallHandler((call) async {
|
||||
final instance = PlatformStreams.instance;
|
||||
switch (call.method) {
|
||||
case Constants.onCurrentDuration:
|
||||
final duration = call.arguments[Constants.current];
|
||||
final key = call.arguments[Constants.playerKey];
|
||||
if (duration.runtimeType == int) {
|
||||
final identifier = PlayerIdentifier<int>(key, duration);
|
||||
instance.addCurrentDurationEvent(identifier);
|
||||
}
|
||||
break;
|
||||
case Constants.onDidFinishPlayingAudio:
|
||||
final key = call.arguments[Constants.playerKey];
|
||||
final playerState =
|
||||
getPlayerState(call.arguments[Constants.finishType]);
|
||||
final stateIdentifier =
|
||||
PlayerIdentifier<PlayerState>(key, playerState);
|
||||
final completionIdentifier = PlayerIdentifier<void>(key, null);
|
||||
instance
|
||||
..addCompletionEvent(completionIdentifier)
|
||||
..addPlayerStateEvent(stateIdentifier)
|
||||
..playerControllerFactory[key]?._playerState = playerState;
|
||||
break;
|
||||
case Constants.onCurrentExtractedWaveformData:
|
||||
var key = call.arguments[Constants.playerKey];
|
||||
var progress = call.arguments[Constants.progress];
|
||||
var waveformData =
|
||||
List<double>.from(call.arguments[Constants.waveformData]);
|
||||
instance.addExtractedWaveformDataEvent(
|
||||
PlayerIdentifier<List<double>>(key, waveformData),
|
||||
);
|
||||
instance.addExtractionProgress(
|
||||
PlayerIdentifier<double>(key, progress),
|
||||
);
|
||||
break;
|
||||
case Constants.onAudioChunk:
|
||||
final normalisedRms = call.arguments[Constants.normalisedRms];
|
||||
final bytes = call.arguments[Constants.bytes];
|
||||
final recordedDuration = call.arguments[Constants.recordedDuration];
|
||||
if (normalisedRms is double) {
|
||||
instance.addAmplitudeEvent(normalisedRms);
|
||||
}
|
||||
if (bytes is Uint8List) {
|
||||
instance.addRecordedBytes(bytes);
|
||||
}
|
||||
if (recordedDuration is int) {
|
||||
instance.addRecordedDurationEvent(recordedDuration);
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
PlayerState getPlayerState(int finishModel) {
|
||||
switch (finishModel) {
|
||||
case 0:
|
||||
return PlayerState.playing;
|
||||
case 1:
|
||||
return PlayerState.paused;
|
||||
default:
|
||||
return PlayerState.stopped;
|
||||
}
|
||||
}
|
||||
|
||||
void removeMethodCallHandler() {
|
||||
_methodChannel.setMethodCallHandler(null);
|
||||
}
|
||||
}
|
||||
62
audio_waveforms/lib/src/base/constants.dart
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
class Constants {
|
||||
Constants._();
|
||||
|
||||
static const String methodChannelName =
|
||||
'simform_audio_waveforms_plugin/methods';
|
||||
static const String initRecorder = 'initRecorder';
|
||||
static const String startRecording = 'startRecording';
|
||||
static const String stopRecording = 'stopRecording';
|
||||
static const String pauseRecording = 'pauseRecording';
|
||||
static const String resumeRecording = 'resumeRecording';
|
||||
static const String getDecibel = 'getDecibel';
|
||||
static const String checkPermission = 'checkPermission';
|
||||
static const String path = 'path';
|
||||
static const String encoder = 'encoder';
|
||||
static const String outputFormat = 'outputFormat';
|
||||
static const String sampleRate = 'sampleRate';
|
||||
static const String bitRate = 'bitRate';
|
||||
static const String readAudioFile = 'readAudioFile';
|
||||
static const String convertToBytes = 'convertToBytes';
|
||||
static const String preparePlayer = "preparePlayer";
|
||||
static const String startPlayer = "startPlayer";
|
||||
static const String stopPlayer = "stopPlayer";
|
||||
static const String releasePlayer = "releasePlayer";
|
||||
static const String pausePlayer = "pausePlayer";
|
||||
static const String seekTo = "seekTo";
|
||||
static const String progress = "progress";
|
||||
static const String setVolume = "setVolume";
|
||||
static const String volume = "volume";
|
||||
static const String finishMode = "finishMode";
|
||||
static const String finishType = "finishType";
|
||||
static const String setRate = "setRate";
|
||||
static const String rate = "rate";
|
||||
static const String rightVolume = "rightVolume";
|
||||
static const String getDuration = "getDuration";
|
||||
static const String durationType = "durationType";
|
||||
static const String seekToStart = "seekToStart";
|
||||
static const String durationEventChannel = "durationEventChannel";
|
||||
static const String playerKey = "playerKey";
|
||||
static const String current = "current";
|
||||
static const String onCurrentDuration = "onCurrentDuration";
|
||||
static const String stopAllPlayers = "stopAllPlayers";
|
||||
static const String pauseAllPlayers = "pauseAllPlayers";
|
||||
static const String onDidFinishPlayingAudio = "onDidFinishPlayingAudio";
|
||||
static const String extractWaveformData = "extractWaveformData";
|
||||
static const String noOfSamples = "noOfSamples";
|
||||
static const String waveformData = "waveformData";
|
||||
static const String onCurrentExtractedWaveformData =
|
||||
"onCurrentExtractedWaveformData";
|
||||
static const String stopExtraction = "stopExtraction";
|
||||
static const String useLegacyNormalization = "useLegacyNormalization";
|
||||
static const String updateFrequency = "updateFrequency";
|
||||
static const String overrideAudioSession = "overrideAudioSession";
|
||||
static const String resultFilePath = "resultFilePath";
|
||||
static const String resultDuration = "resultDuration";
|
||||
static const String linearPCMBitDepth = 'linearPCMBitDepth';
|
||||
static const String linearPCMIsBigEndian = 'linearPCMIsBigEndian';
|
||||
static const String linearPCMIsFloat = 'linearPCMIsFloat';
|
||||
static const String onAudioChunk = 'onAudioChunk';
|
||||
static const String normalisedRms = 'normalisedRms';
|
||||
static const String bytes = 'bytes';
|
||||
static const String recordedDuration = 'recordedDuration';
|
||||
}
|
||||
16
audio_waveforms/lib/src/base/label.dart
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
///Duration labels for AudioWaveform widget.
|
||||
class Label {
|
||||
Label({
|
||||
required this.content,
|
||||
required this.offset,
|
||||
});
|
||||
|
||||
/// Fixed label content for a single instance.
|
||||
final String content;
|
||||
|
||||
/// An offset for labels which get new position everytime waveforms are
|
||||
/// scrolled.
|
||||
Offset offset;
|
||||
}
|
||||
132
audio_waveforms/lib/src/base/platform_streams.dart
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import '../../audio_waveforms.dart';
|
||||
import 'player_identifier.dart';
|
||||
|
||||
///This class should be used for any type of native streams.
|
||||
class PlatformStreams {
|
||||
PlatformStreams._();
|
||||
|
||||
///This holds all the newly created [PlayerController] instances and
|
||||
///the key to identify. it is a [Unique] key created along with
|
||||
///PlayerController.
|
||||
final Map<String, PlayerController> playerControllerFactory = {};
|
||||
|
||||
static PlatformStreams instance = PlatformStreams._();
|
||||
|
||||
bool isInitialised = false;
|
||||
|
||||
/// Initialises native method call handlers and stream. Should be called
|
||||
/// only once before [dispose].
|
||||
Future<void> init() async {
|
||||
// Requires to be set before waiting for method call handler to be
|
||||
// initialised due to race condition when using widget in ListView.builder.
|
||||
isInitialised = true;
|
||||
|
||||
_currentDurationController =
|
||||
StreamController<PlayerIdentifier<int>>.broadcast();
|
||||
_playerStateController =
|
||||
StreamController<PlayerIdentifier<PlayerState>>.broadcast();
|
||||
_extractedWaveformDataController =
|
||||
StreamController<PlayerIdentifier<List<double>>>.broadcast();
|
||||
_extractionProgressController =
|
||||
StreamController<PlayerIdentifier<double>>.broadcast();
|
||||
_completionController =
|
||||
StreamController<PlayerIdentifier<void>>.broadcast();
|
||||
_recordingAmplitudeController = StreamController<double>.broadcast();
|
||||
_recordedBytesController = StreamController<Uint8List>.broadcast();
|
||||
_recordedDurationController = StreamController<Duration>.broadcast();
|
||||
await AudioWaveformsInterface.instance.setMethodCallHandler();
|
||||
}
|
||||
|
||||
Stream<PlayerIdentifier<int>> get onDurationChanged =>
|
||||
_currentDurationController.stream;
|
||||
|
||||
Stream<PlayerIdentifier<PlayerState>> get onPlayerStateChanged =>
|
||||
_playerStateController.stream;
|
||||
|
||||
Stream<PlayerIdentifier<List<double>>> get onCurrentExtractedWaveformData =>
|
||||
_extractedWaveformDataController.stream;
|
||||
|
||||
Stream<PlayerIdentifier<double>> get onExtractionProgress =>
|
||||
_extractionProgressController.stream;
|
||||
|
||||
Stream<PlayerIdentifier<void>> get onCompletion =>
|
||||
_completionController.stream;
|
||||
|
||||
Stream<double> get onAmplitude => _recordingAmplitudeController.stream;
|
||||
|
||||
Stream<Uint8List> get onRecordedBytes => _recordedBytesController.stream;
|
||||
|
||||
Stream<Duration> get onCurrentDuration => _recordedDurationController.stream;
|
||||
|
||||
late StreamController<PlayerIdentifier<int>> _currentDurationController;
|
||||
late StreamController<PlayerIdentifier<PlayerState>> _playerStateController;
|
||||
late StreamController<PlayerIdentifier<List<double>>>
|
||||
_extractedWaveformDataController;
|
||||
late StreamController<PlayerIdentifier<double>> _extractionProgressController;
|
||||
late StreamController<PlayerIdentifier<void>> _completionController;
|
||||
late StreamController<double> _recordingAmplitudeController;
|
||||
late StreamController<Uint8List> _recordedBytesController;
|
||||
late StreamController<Duration> _recordedDurationController;
|
||||
|
||||
void addCurrentDurationEvent(PlayerIdentifier<int> playerIdentifier) {
|
||||
if (!_currentDurationController.isClosed) {
|
||||
_currentDurationController.add(playerIdentifier);
|
||||
}
|
||||
}
|
||||
|
||||
void addPlayerStateEvent(PlayerIdentifier<PlayerState> playerIdentifier) {
|
||||
if (!_playerStateController.isClosed) {
|
||||
_playerStateController.add(playerIdentifier);
|
||||
}
|
||||
}
|
||||
|
||||
void addExtractedWaveformDataEvent(
|
||||
PlayerIdentifier<List<double>> playerIdentifier,
|
||||
) {
|
||||
if (!_extractedWaveformDataController.isClosed) {
|
||||
_extractedWaveformDataController.add(playerIdentifier);
|
||||
}
|
||||
}
|
||||
|
||||
void addExtractionProgress(PlayerIdentifier<double> progress) {
|
||||
if (!_extractionProgressController.isClosed) {
|
||||
_extractionProgressController.add(progress);
|
||||
}
|
||||
}
|
||||
|
||||
void addCompletionEvent(PlayerIdentifier<void> event) {
|
||||
if (!_completionController.isClosed) {
|
||||
_completionController.add(event);
|
||||
}
|
||||
}
|
||||
|
||||
void addAmplitudeEvent(double event) {
|
||||
if (_recordingAmplitudeController.isClosed) return;
|
||||
_recordingAmplitudeController.add(event);
|
||||
}
|
||||
|
||||
void addRecordedBytes(Uint8List event) {
|
||||
if (_recordedBytesController.isClosed) return;
|
||||
_recordedBytesController.add(event);
|
||||
}
|
||||
|
||||
void addRecordedDurationEvent(int milliSeconds) {
|
||||
if (_recordedDurationController.isClosed) return;
|
||||
_recordedDurationController.add(Duration(milliseconds: milliSeconds));
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_currentDurationController.close();
|
||||
_playerStateController.close();
|
||||
_extractedWaveformDataController.close();
|
||||
_completionController.close();
|
||||
_recordingAmplitudeController.close();
|
||||
_recordedBytesController.close();
|
||||
_recordedDurationController.close();
|
||||
AudioWaveformsInterface.instance.removeMethodCallHandler();
|
||||
isInitialised = false;
|
||||
}
|
||||
}
|
||||
10
audio_waveforms/lib/src/base/player_identifier.dart
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
/// This class is to identify player associated with any generic type.
|
||||
class PlayerIdentifier<T> {
|
||||
PlayerIdentifier(this.playerKey, this.type);
|
||||
|
||||
/// An unique key associated with player.
|
||||
String playerKey;
|
||||
|
||||
/// A generic type which is associated to player
|
||||
T type;
|
||||
}
|
||||
83
audio_waveforms/lib/src/base/player_wave_style.dart
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
class PlayerWaveStyle {
|
||||
const PlayerWaveStyle({
|
||||
this.fixedWaveColor = Colors.white54,
|
||||
this.liveWaveColor = Colors.white,
|
||||
this.showTop = true,
|
||||
this.showBottom = true,
|
||||
this.showSeekLine = true,
|
||||
this.waveCap = StrokeCap.round,
|
||||
this.seekLineColor = Colors.white,
|
||||
this.seekLineThickness = 2.0,
|
||||
this.waveThickness = 3.0,
|
||||
this.backgroundColor = Colors.black,
|
||||
this.fixedWaveGradient,
|
||||
this.scaleFactor = 100.0,
|
||||
this.liveWaveGradient,
|
||||
this.spacing = 5,
|
||||
this.scrollScale = 1.0,
|
||||
}) : assert(spacing >= 0),
|
||||
assert(
|
||||
waveThickness < spacing,
|
||||
"waveThickness can't be greater than spacing",
|
||||
);
|
||||
|
||||
///Color of the [wave] which is behind the live wave.
|
||||
final Color fixedWaveColor;
|
||||
|
||||
///Color of the [live] wave which indicates currently played part.
|
||||
final Color liveWaveColor;
|
||||
|
||||
/// Space between two waves.
|
||||
final double spacing;
|
||||
|
||||
///Whether to show upper wave or not defaults to true
|
||||
final bool showTop;
|
||||
|
||||
///Whether to show bottom wave or not default to true
|
||||
final bool showBottom;
|
||||
|
||||
/// The kind of finish to place on the end of lines drawn.
|
||||
/// Default to StrokeCap.round
|
||||
final StrokeCap waveCap;
|
||||
|
||||
/// Color line in the middle
|
||||
final Color seekLineColor;
|
||||
|
||||
/// Thickness of seek line. For microphone recording this line
|
||||
/// is in the middle.
|
||||
final double seekLineThickness;
|
||||
|
||||
/// Width of each wave
|
||||
final double waveThickness;
|
||||
|
||||
/// The background color of waveform box default is Black
|
||||
final Color backgroundColor;
|
||||
|
||||
/// Provide gradient to waves which is behind the live wave.
|
||||
/// Use shader as shown in example.
|
||||
final Shader? fixedWaveGradient;
|
||||
|
||||
/// This is applied to each wave while generating.
|
||||
/// Use this to scale the waves. Defaults to 100.0.
|
||||
final double scaleFactor;
|
||||
|
||||
/// This gradient is applied to waves which indicates currently played part.
|
||||
final Shader? liveWaveGradient;
|
||||
|
||||
/// Scales the wave when waveforms are seeked. The scaled waves returns back
|
||||
/// to original scale when gesture ends. To get result set value greater then
|
||||
/// 1.
|
||||
final double scrollScale;
|
||||
|
||||
/// Shows seek line in the middle when enabled.
|
||||
final bool showSeekLine;
|
||||
|
||||
/// Determines number of samples which will fit in provided width.
|
||||
/// Returned number of samples are also dependent on [spacing] set for
|
||||
/// this constructor.
|
||||
int getSamplesForWidth(double width) {
|
||||
return width ~/ spacing;
|
||||
}
|
||||
}
|
||||
201
audio_waveforms/lib/src/base/utils.dart
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
import 'player_identifier.dart';
|
||||
|
||||
//ignore_for_file: constant_identifier_names
|
||||
extension DurationExtension on Duration {
|
||||
/// Converts duration to HH:MM:SS format
|
||||
String toHHMMSS() => toString().split('.').first.padLeft(8, "0");
|
||||
}
|
||||
|
||||
extension IntExtension on int {
|
||||
/// Converts total seconds to MM:SS format
|
||||
String toMMSS() =>
|
||||
'${(this ~/ 60).toString().padLeft(2, '0')}:${(this % 60).toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
/// State of recorder
|
||||
enum RecorderState { initialized, recording, paused, stopped }
|
||||
|
||||
/// Android encoders.
|
||||
///
|
||||
/// Android and IOS are have been separated to better support
|
||||
/// platform wise encoder and output formats.
|
||||
///
|
||||
/// Check [MediaRecorder.AudioEncoder](https://developer.android.com/reference/android/media/MediaRecorder.AudioEncoder)
|
||||
/// for more info.
|
||||
enum AndroidEncoder {
|
||||
wav,
|
||||
aacLc,
|
||||
aacHe,
|
||||
aacEld,
|
||||
amrNb,
|
||||
amrWb,
|
||||
opus;
|
||||
|
||||
String toNativeFormat() {
|
||||
return switch (this) {
|
||||
AndroidEncoder.wav => 'WAV',
|
||||
AndroidEncoder.aacLc => 'AAC_LC',
|
||||
AndroidEncoder.aacHe => 'AAC_HE',
|
||||
AndroidEncoder.aacEld => 'AAC_ELD',
|
||||
AndroidEncoder.amrNb => 'AMR_NB',
|
||||
AndroidEncoder.amrWb => 'AMR_WB',
|
||||
AndroidEncoder.opus => 'OPUS',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// IOS encoders.
|
||||
///
|
||||
/// Android and IOS are have been separated to better support
|
||||
/// platform wise encoder and output formats.
|
||||
///
|
||||
/// Check [Audio Format Identifiers](https://developer.apple.com/documentation/coreaudiotypes/1572096-audio_format_identifiers)
|
||||
/// for more info.
|
||||
enum IosEncoder {
|
||||
/// Default
|
||||
kAudioFormatMPEG4AAC,
|
||||
kAudioFormatMPEGLayer1,
|
||||
kAudioFormatMPEGLayer2,
|
||||
kAudioFormatMPEGLayer3,
|
||||
kAudioFormatMPEG4AAC_ELD,
|
||||
kAudioFormatMPEG4AAC_HE,
|
||||
kAudioFormatOpus,
|
||||
kAudioFormatAMR,
|
||||
kAudioFormatAMR_WB,
|
||||
kAudioFormatLinearPCM,
|
||||
kAudioFormatAppleLossless,
|
||||
kAudioFormatMPEG4AAC_HE_V2
|
||||
}
|
||||
|
||||
/// States of audio player
|
||||
enum PlayerState {
|
||||
/// When player is [initialised]
|
||||
initialized,
|
||||
|
||||
/// When player is playing the audio file
|
||||
playing,
|
||||
|
||||
/// When player is paused.
|
||||
paused,
|
||||
|
||||
/// when player is stopped. Default state of any player ([uninitialised]).
|
||||
stopped
|
||||
}
|
||||
|
||||
/// There are two type duration which we can get while playing an audio.
|
||||
///
|
||||
/// 1. max -: Max duration is [full] duration of audio file
|
||||
///
|
||||
/// 2. current -: Current duration is how much audio has been played
|
||||
enum DurationType {
|
||||
current,
|
||||
|
||||
/// Default
|
||||
max
|
||||
}
|
||||
|
||||
/// This extension filter playerKey from the stream and provides
|
||||
/// only necessary generic type.
|
||||
extension FilterForPlayer<T> on Stream<PlayerIdentifier<T>> {
|
||||
Stream<T> filter(String playerKey) {
|
||||
return where((identifier) => identifier.playerKey == playerKey)
|
||||
.map((identifier) => identifier.type);
|
||||
}
|
||||
}
|
||||
|
||||
/// An enum to be used to change behaviour of player when audio
|
||||
/// is finished playing.
|
||||
enum FinishMode {
|
||||
///Keeps the buffered data and plays again after completion, creating a loop.
|
||||
loop,
|
||||
|
||||
///Stop audio playback but keep all resources intact.
|
||||
///Use this if you intend to play again later.
|
||||
pause,
|
||||
|
||||
///Stops player and disposes it(a PlayerController won't be disposed).
|
||||
stop,
|
||||
}
|
||||
|
||||
/// An enum to decide which type of waveform to show.
|
||||
enum WaveformType {
|
||||
/// Fits Waveform in provided width. Audio can be seeked with
|
||||
/// tap and drag gesture.
|
||||
///
|
||||
/// **Important**-: Make sure to provide number of sample according to
|
||||
/// the width using `getSamplesForWidth` function from PlayerWaveStyle
|
||||
/// otherwise full waveform may get cut off.
|
||||
fitWidth,
|
||||
|
||||
/// This waveform starts from middle. When audio progresses waveform is
|
||||
/// pushed back and a middle line shows current progress.
|
||||
///
|
||||
/// This waveform only allows seek with drag.
|
||||
long
|
||||
}
|
||||
|
||||
extension WaveformTypeExtension on WaveformType {
|
||||
/// Check WaveformType is equals to fitWidth or not.
|
||||
bool get isFitWidth => this == WaveformType.fitWidth;
|
||||
|
||||
/// Check WaveformType is equals to long or not.
|
||||
bool get isLong => this == WaveformType.long;
|
||||
}
|
||||
|
||||
extension PlayerStateExtension on PlayerState {
|
||||
bool get isPlaying => this == PlayerState.playing;
|
||||
|
||||
bool get isStopped => this == PlayerState.stopped;
|
||||
|
||||
bool get isInitialised => this == PlayerState.initialized;
|
||||
|
||||
bool get isPaused => this == PlayerState.paused;
|
||||
}
|
||||
|
||||
extension RecorderStateExtension on RecorderState {
|
||||
bool get isRecording => this == RecorderState.recording;
|
||||
|
||||
bool get isInitialized => this == RecorderState.initialized;
|
||||
|
||||
bool get isPaused => this == RecorderState.paused;
|
||||
|
||||
bool get isStopped => this == RecorderState.stopped;
|
||||
}
|
||||
|
||||
/// Rate of updating the reported current duration.
|
||||
enum UpdateFrequency {
|
||||
/// Reports duration at every 50 milliseconds.
|
||||
high(50),
|
||||
|
||||
/// Reports duration at every 100 milliseconds.
|
||||
medium(100),
|
||||
|
||||
/// Reports duration at every 200 milliseconds.
|
||||
low(200);
|
||||
|
||||
const UpdateFrequency(this.value);
|
||||
|
||||
final int value;
|
||||
}
|
||||
|
||||
/// An enum to decide waveform rendering mode.
|
||||
enum WaveformRenderMode {
|
||||
/// Normal mode where waveform starts from left to right.
|
||||
///
|
||||
/// The waveform will render from left to right. Once rendered waveforms
|
||||
/// reaches the end of the available width, it will start pushing the
|
||||
/// previous waves to left to make space for new waves.
|
||||
ltr,
|
||||
|
||||
/// RTL mode where waveform starts from right to left.
|
||||
///
|
||||
/// The waveform will render from right to left. Older waves will be pushed
|
||||
/// to the left to make space for new waves.
|
||||
rtl;
|
||||
|
||||
/// Check WaveformRenderMode is equals to ltr or not.
|
||||
bool get isLtr => this == WaveformRenderMode.ltr;
|
||||
|
||||
/// Check WaveformRenderMode is equals to rtl or not.
|
||||
bool get isRtl => this == WaveformRenderMode.rtl;
|
||||
}
|
||||
28
audio_waveforms/lib/src/base/wave_clipper.dart
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
///This clipper clips left and right side of waveform square so that waves
|
||||
///doesn't get outside of the bounds
|
||||
///
|
||||
/// This clipper also allows extra clipping height to label so that they can be
|
||||
/// visible.
|
||||
class WaveClipper extends CustomClipper<Path> {
|
||||
WaveClipper({
|
||||
required this.extraClipperHeight,
|
||||
this.waveWidth = 0,
|
||||
});
|
||||
|
||||
final double extraClipperHeight;
|
||||
final double waveWidth;
|
||||
|
||||
@override
|
||||
getClip(Size size) {
|
||||
final path = Path()
|
||||
..lineTo(0, size.height + extraClipperHeight)
|
||||
..lineTo(size.width - waveWidth, size.height + extraClipperHeight)
|
||||
..lineTo(size.width - waveWidth, 0);
|
||||
return path;
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldReclip(WaveClipper oldClipper) => false;
|
||||
}
|
||||
136
audio_waveforms/lib/src/base/wave_style.dart
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../audio_waveforms.dart';
|
||||
|
||||
class WaveStyle {
|
||||
/// A model class to provide style to the waveforms.
|
||||
const WaveStyle({
|
||||
this.waveColor = Colors.blueGrey,
|
||||
this.showMiddleLine = true,
|
||||
this.spacing = 8.0,
|
||||
this.showTop = true,
|
||||
this.showBottom = true,
|
||||
this.bottomPadding,
|
||||
this.waveCap = StrokeCap.round,
|
||||
this.middleLineColor = Colors.redAccent,
|
||||
this.middleLineThickness = 3.0,
|
||||
this.waveThickness = 3.0,
|
||||
this.showDurationLabel = false,
|
||||
this.extendWaveform = false,
|
||||
this.backgroundColor = Colors.black,
|
||||
this.showHourInDuration = false,
|
||||
this.durationLinesHeight = 16.0,
|
||||
this.durationStyle = const TextStyle(
|
||||
color: Colors.red,
|
||||
fontSize: 16.0,
|
||||
),
|
||||
this.extraClipperHeight,
|
||||
this.labelSpacing = 16.0,
|
||||
this.durationTextPadding = 20.0,
|
||||
this.durationLinesColor = Colors.blueAccent,
|
||||
this.gradient,
|
||||
this.scaleFactor = 20.0,
|
||||
this.waveformRenderMode = WaveformRenderMode.ltr,
|
||||
}) : assert(
|
||||
waveThickness < spacing,
|
||||
"waveThickness can't be greater than spacing",
|
||||
);
|
||||
|
||||
/// Color of the [Wave].
|
||||
final Color waveColor;
|
||||
|
||||
/// Whether to show line in the middle defaults to true
|
||||
final bool showMiddleLine;
|
||||
|
||||
/// Space between each wave
|
||||
final double spacing;
|
||||
|
||||
/// Whether to show upper wave or not defaults to true
|
||||
final bool showTop;
|
||||
|
||||
/// Whether to show bottom wave or not default to true
|
||||
final bool showBottom;
|
||||
|
||||
/// Wave padding from bottom. Default to size.height/2.
|
||||
final double? bottomPadding;
|
||||
|
||||
/// The kind of finish to place on the end of lines drawn
|
||||
/// default to StrokeCap.round
|
||||
final StrokeCap waveCap;
|
||||
|
||||
/// Color line in the middle
|
||||
final Color middleLineColor;
|
||||
|
||||
/// Thickness of middle line.
|
||||
final double middleLineThickness;
|
||||
|
||||
/// Width of each wave
|
||||
final double waveThickness;
|
||||
|
||||
/// The background color of waveform box default is Black
|
||||
final Color backgroundColor;
|
||||
|
||||
/// Extend the wave to the end of size.width, default is size.width/2.
|
||||
/// Can only be used with [WaveformRenderMode.ltr] mode.
|
||||
/// For [WaveformRenderMode.rtl], this will be ignored.
|
||||
final bool extendWaveform;
|
||||
|
||||
/// Show duration labels. Default is false
|
||||
final bool showDurationLabel;
|
||||
|
||||
/// Show duration label in HH:MM:SS format. Default is MM:SS
|
||||
final bool showHourInDuration;
|
||||
|
||||
/// Text style for duration labels
|
||||
final TextStyle durationStyle;
|
||||
|
||||
/// Color of duration lines
|
||||
final Color durationLinesColor;
|
||||
|
||||
/// Height of duration lines
|
||||
final double durationLinesHeight;
|
||||
|
||||
/// Space between duration labels and waveform square
|
||||
final double labelSpacing;
|
||||
|
||||
/// It might happen that label text gets cut or have extra clipping.
|
||||
///
|
||||
/// So provided +Ve value add more clipping and -Ve will reduce
|
||||
/// the clipping.
|
||||
final double? extraClipperHeight;
|
||||
|
||||
/// Value > 0 will be padded right and value < 0 will be padded left.
|
||||
final double durationTextPadding;
|
||||
|
||||
/// Applies this gradient to waveforms.
|
||||
///
|
||||
/// **Use as below**
|
||||
///
|
||||
/// ```dart
|
||||
/// import 'dart:ui' as ui show Gradient;
|
||||
///
|
||||
/// ...
|
||||
///
|
||||
/// ui.Gradient.linear(
|
||||
/// const Offset(70, 50),
|
||||
/// Offset(MediaQuery.of(context).size.width / 2, 0),
|
||||
/// [Colors.red, Colors.green],
|
||||
/// ),
|
||||
/// ```dart
|
||||
final Shader? gradient;
|
||||
|
||||
/// Default normalised amplitude/power we have are between 0.0 and 1.0.
|
||||
/// So scale them, [scaleFactor] can be used. Defaults to 20.0.
|
||||
final double scaleFactor;
|
||||
|
||||
/// Defines the rendering direction of the waveform. By default, it is set to
|
||||
/// [WaveformRenderMode.ltr]. Which means the waveform will render from left
|
||||
/// to right. Once rendered waveforms reaches the end of the available width,
|
||||
/// it will start pushing the previous waves to left to make space for new
|
||||
/// waves.
|
||||
///
|
||||
/// If set to [WaveformRenderMode.rtl], the waveform will render from right
|
||||
/// to left. Older waves will be pushed to the left to make space for new
|
||||
/// waves.
|
||||
final WaveformRenderMode waveformRenderMode;
|
||||
}
|
||||
385
audio_waveforms/lib/src/controllers/player_controller.dart
Normal file
|
|
@ -0,0 +1,385 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../../audio_waveforms.dart';
|
||||
import '../base/constants.dart';
|
||||
import '../base/platform_streams.dart';
|
||||
import '../base/player_identifier.dart';
|
||||
|
||||
part '../base/audio_waveforms_interface.dart';
|
||||
part 'waveform_extraction_controller.dart';
|
||||
|
||||
class PlayerController extends ChangeNotifier {
|
||||
PlayerController() {
|
||||
if (!PlatformStreams.instance.isInitialised) {
|
||||
PlatformStreams.instance.init();
|
||||
}
|
||||
PlatformStreams.instance.playerControllerFactory.addAll({playerKey: this});
|
||||
}
|
||||
|
||||
PlayerState _playerState = PlayerState.stopped;
|
||||
|
||||
/// Provides current state of the player
|
||||
PlayerState get playerState => _playerState;
|
||||
|
||||
bool _shouldRefresh = true;
|
||||
|
||||
bool get shouldRefresh => _shouldRefresh;
|
||||
|
||||
bool _isDisposed = false;
|
||||
|
||||
int _maxDuration = -1;
|
||||
|
||||
/// Provides [max] duration of currently provided audio file.
|
||||
int get maxDuration => _maxDuration;
|
||||
|
||||
/// An unique key string associated with [this] player only
|
||||
final playerKey = shortHash(UniqueKey());
|
||||
|
||||
/// An [WaveformExtractionController] instance which is bound
|
||||
/// with [PlayerController]
|
||||
/// using [playerKey] and [WaveformExtractionController._extractorKey]
|
||||
///
|
||||
/// It can be used to extract waveform data, stop extraction
|
||||
/// or listen to waveform data changed and progress.
|
||||
late final waveformExtraction = WaveformExtractionController._(playerKey);
|
||||
|
||||
final bool _shouldClearLabels = false;
|
||||
|
||||
bool get shouldClearLabels => _shouldClearLabels;
|
||||
|
||||
/// Rate of updating the reported current duration. Making it high will
|
||||
/// cause reporting duration at faster rate which also causes UI to look
|
||||
/// smoother.
|
||||
///
|
||||
/// **Important** -: As duration is reported from platform, low-end devices
|
||||
/// may have higher impact if UpdateFrequency is set to high.
|
||||
///
|
||||
/// Defaults to low (updates every 200 milliseconds).
|
||||
///
|
||||
/// See also:
|
||||
/// * [UpdateFrequency]
|
||||
UpdateFrequency updateFrequency = UpdateFrequency.low;
|
||||
|
||||
/// IOS only.
|
||||
///
|
||||
/// Overrides AVAudioSession settings with
|
||||
/// ```
|
||||
/// AVAudioSession.Category: .playback
|
||||
/// AVAudioSession.CategoryOptions: [.default]
|
||||
/// ```
|
||||
/// You may use your implementation to set your preferred configurations.
|
||||
/// Changes to this property will only take effect after you call
|
||||
/// [preparePlayer].
|
||||
///
|
||||
/// Setting this property to true will set the AudioSession in native
|
||||
/// otherwise nothing happens.
|
||||
///
|
||||
/// Defaults to false.
|
||||
bool overrideAudioSession = false;
|
||||
|
||||
/// A stream to get current state of the player. This stream
|
||||
/// will emit event whenever there is change in the playerState.
|
||||
Stream<PlayerState> get onPlayerStateChanged =>
|
||||
PlatformStreams.instance.onPlayerStateChanged.filter(playerKey);
|
||||
|
||||
/// A stream to get current duration. This stream will emit
|
||||
/// every 200 milliseconds. Emitted duration is in milliseconds.
|
||||
Stream<int> get onCurrentDurationChanged =>
|
||||
PlatformStreams.instance.onDurationChanged.filter(playerKey);
|
||||
|
||||
/// A stream to get events when audio is finished playing.
|
||||
Stream<void> get onCompletion =>
|
||||
PlatformStreams.instance.onCompletion.filter(playerKey);
|
||||
|
||||
void _setPlayerState(PlayerState state) {
|
||||
_playerState = state;
|
||||
PlatformStreams.instance
|
||||
.addPlayerStateEvent(PlayerIdentifier(playerKey, state));
|
||||
}
|
||||
|
||||
/// Calls platform to prepare player.
|
||||
///
|
||||
/// Path is required parameter for providing location of the
|
||||
/// audio file.
|
||||
///
|
||||
/// [volume] is optional parameters with minimum value 0.0 is treated
|
||||
/// as mute and 1.0 as max volume. Providing value greater 1.0 is also
|
||||
/// treated same as 1.0 (max volume).
|
||||
///
|
||||
/// Waveforms also will be extracted when with function which can be
|
||||
/// accessed using [waveformData]. Passing false to [shouldExtractWaveform]
|
||||
/// will prevent extracting of waveforms.
|
||||
///
|
||||
/// Waveforms also can be extracted using [extractWaveformData] function
|
||||
/// which can be stored locally or over the server. This data can be passed
|
||||
/// directly passed to AudioFileWaveforms widget.
|
||||
/// This will save the resources when extracting waveforms for same file
|
||||
/// everytime.
|
||||
///
|
||||
/// [noOfSamples] indicates no of extracted data points. This will determine
|
||||
/// number of bars in the waveform.
|
||||
///
|
||||
/// Defaults to 100 if both [noOfSamples] and [noOfSamplesPerSecond] are null.
|
||||
///
|
||||
/// [noOfSamplesPerSecond] can be used as an alternative to [noOfSamples] to specify
|
||||
/// the number of samples per second of audio. The actual [noOfSamples] will
|
||||
/// be calculated as: noOfSamplesPerSecond * durationInSeconds.
|
||||
/// This is useful when the full duration is not known in advance.
|
||||
///
|
||||
/// **Important**: Provide only ONE of [noOfSamples] OR [noOfSamplesPerSecond], not both.
|
||||
/// - To use fixed sample count: provide only [noOfSamples]
|
||||
/// - To use samples per second: provide only [noOfSamplesPerSecond]
|
||||
/// - If both are null, defaults to [noOfSamples] = 100
|
||||
Future<void> preparePlayer({
|
||||
required String path,
|
||||
double? volume,
|
||||
bool shouldExtractWaveform = true,
|
||||
int? noOfSamples,
|
||||
int? noOfSamplesPerSecond,
|
||||
}) async {
|
||||
// Validate that user doesn't provide both parameters
|
||||
assert(
|
||||
!(noOfSamples != null && noOfSamplesPerSecond != null),
|
||||
'Cannot provide both noOfSamples and noOfSamplesPerSecond. '
|
||||
'Use noOfSamples for fixed count OR noOfSamplesPerSecond for dynamic calculation based on duration.',
|
||||
);
|
||||
|
||||
if (!path.startsWith('http')) {
|
||||
// Keep the full URL for remote files and strip for local files
|
||||
final uri = Uri.tryParse(path);
|
||||
if (uri == null) {
|
||||
throw ArgumentError('Invalid path format: $path');
|
||||
}
|
||||
path = uri.path;
|
||||
}
|
||||
final isPrepared = await AudioWaveformsInterface.instance.preparePlayer(
|
||||
path: path,
|
||||
key: playerKey,
|
||||
frequency: updateFrequency.value,
|
||||
volume: volume,
|
||||
overrideAudioSession: overrideAudioSession,
|
||||
);
|
||||
if (isPrepared) {
|
||||
_maxDuration = await getDuration();
|
||||
_setPlayerState(PlayerState.initialized);
|
||||
}
|
||||
|
||||
if (shouldExtractWaveform) {
|
||||
// Determine which sampling strategy to use
|
||||
final int actualNoOfSamples;
|
||||
if (noOfSamplesPerSecond != null) {
|
||||
// Use dynamic calculation based on duration
|
||||
if (_maxDuration > 0) {
|
||||
actualNoOfSamples =
|
||||
(noOfSamplesPerSecond * (_maxDuration / 1000)).round();
|
||||
} else {
|
||||
actualNoOfSamples =
|
||||
noOfSamplesPerSecond; // Fallback if duration unavailable
|
||||
}
|
||||
} else {
|
||||
// Use fixed sample count (default to 100 if not provided)
|
||||
actualNoOfSamples = noOfSamples ?? 100;
|
||||
}
|
||||
|
||||
waveformExtraction
|
||||
.extractWaveformData(
|
||||
path: path,
|
||||
noOfSamples: actualNoOfSamples,
|
||||
)
|
||||
.then(
|
||||
(value) {
|
||||
waveformExtraction.waveformData
|
||||
..clear()
|
||||
..addAll(value);
|
||||
notifyListeners();
|
||||
},
|
||||
);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// A function to start the player to play/resume the audio.
|
||||
///
|
||||
/// When playing audio is finished, this [player] will be [stopped]
|
||||
/// and [disposed] by default. To change this behavior use [setFinishMode] method.
|
||||
///
|
||||
Future<void> startPlayer({
|
||||
bool forceRefresh = true,
|
||||
}) async {
|
||||
if (_playerState == PlayerState.initialized ||
|
||||
_playerState == PlayerState.paused) {
|
||||
final isStarted =
|
||||
await AudioWaveformsInterface.instance.startPlayer(playerKey);
|
||||
if (isStarted) {
|
||||
_setPlayerState(PlayerState.playing);
|
||||
} else {
|
||||
throw "Failed to start player";
|
||||
}
|
||||
}
|
||||
_setRefresh(forceRefresh);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Pauses currently playing audio.
|
||||
Future<void> pausePlayer() async {
|
||||
final isPaused =
|
||||
await AudioWaveformsInterface.instance.pausePlayer(playerKey);
|
||||
if (isPaused) {
|
||||
_setPlayerState(PlayerState.paused);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// A function to stop player.
|
||||
Future<void> stopPlayer() async {
|
||||
final isStopped =
|
||||
await AudioWaveformsInterface.instance.stopPlayer(playerKey);
|
||||
if (isStopped) {
|
||||
_setPlayerState(PlayerState.stopped);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Releases the resources associated with this player.
|
||||
Future<void> release() async {
|
||||
await AudioWaveformsInterface.instance.release(playerKey);
|
||||
}
|
||||
|
||||
/// Sets volume for this player. Doesn't throw Exception.
|
||||
/// Returns false if it couldn't set the volume.
|
||||
///
|
||||
/// Minimum value [0.0] is treated as mute and 1.0 as max volume.
|
||||
/// Providing value greater 1.0 is also treated same as 1.0 (max volume).
|
||||
///
|
||||
/// Default to 1.0
|
||||
Future<bool> setVolume(double volume) async {
|
||||
final result =
|
||||
await AudioWaveformsInterface.instance.setVolume(volume, playerKey);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Sets playback rate for this player. Doesn't throw Exception.
|
||||
/// Returns false if it couldn't set the rate.
|
||||
///
|
||||
/// Default to 1.0
|
||||
Future<bool> setRate(double rate) async {
|
||||
final result =
|
||||
await AudioWaveformsInterface.instance.setRate(rate, playerKey);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Returns maximum duration for [DurationType.max] and
|
||||
/// current duration for [DurationType.current] for playing media.
|
||||
/// The duration is in milliseconds, if no duration is available
|
||||
/// -1 is returned.
|
||||
///
|
||||
/// Default to Duration.max.
|
||||
Future<int> getDuration([DurationType? durationType]) async {
|
||||
final duration = await AudioWaveformsInterface.instance
|
||||
.getDuration(playerKey, durationType?.index ?? 1);
|
||||
return duration ?? -1;
|
||||
}
|
||||
|
||||
/// Moves the media to specified time(milliseconds) position.
|
||||
///
|
||||
/// Minimum Android [O] is required to use this function
|
||||
/// otherwise nothing happens.
|
||||
Future<void> seekTo(int progress) async {
|
||||
if (progress < 0 || _playerState.isStopped) return;
|
||||
|
||||
await AudioWaveformsInterface.instance.seekTo(playerKey, progress);
|
||||
}
|
||||
|
||||
/// This method will be used to change behaviour of player when audio
|
||||
/// is finished playing.
|
||||
///
|
||||
/// Check[FinishMode]'s doc to understand the difference between the modes.
|
||||
Future<void> setFinishMode({
|
||||
FinishMode finishMode = FinishMode.stop,
|
||||
}) async {
|
||||
return AudioWaveformsInterface.instance.setReleaseMode(
|
||||
playerKey,
|
||||
finishMode,
|
||||
);
|
||||
}
|
||||
|
||||
/// Release any resources taken by this controller. Disposing this
|
||||
/// will stop the player and release resources from native.
|
||||
///
|
||||
/// If this is last remaining controller then it will also dispose
|
||||
/// the platform stream. They can be re-initialised by initialising a
|
||||
/// new controller.
|
||||
@override
|
||||
void dispose() async {
|
||||
if (playerState != PlayerState.stopped) await stopPlayer();
|
||||
await release();
|
||||
await waveformExtraction.stopWaveformExtraction();
|
||||
PlatformStreams.instance.playerControllerFactory.remove(playerKey);
|
||||
if (PlatformStreams.instance.playerControllerFactory.isEmpty) {
|
||||
PlatformStreams.instance.dispose();
|
||||
}
|
||||
_isDisposed = true;
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Frees [resources] used by all players simultaneously.
|
||||
///
|
||||
/// This method closes the stream and releases resources allocated by all
|
||||
/// players. Note that it does not dispose of the controller.
|
||||
///
|
||||
/// Returns `true` if all players stop successfully, otherwise returns `false`.
|
||||
Future<bool> stopAllPlayers() async {
|
||||
PlatformStreams.instance.dispose();
|
||||
var isAllPlayersStopped =
|
||||
await AudioWaveformsInterface.instance.stopAllPlayers();
|
||||
if (isAllPlayersStopped) {
|
||||
PlatformStreams.instance.playerControllerFactory
|
||||
.forEach((playKey, controller) {
|
||||
controller._setPlayerState(PlayerState.stopped);
|
||||
});
|
||||
}
|
||||
return isAllPlayersStopped;
|
||||
}
|
||||
|
||||
/// Pauses all the players. Works similar to stopAllPlayer.
|
||||
Future<bool> pauseAllPlayers() async {
|
||||
var isAllPlayersPaused =
|
||||
await AudioWaveformsInterface.instance.pauseAllPlayers();
|
||||
if (isAllPlayersPaused) {
|
||||
PlatformStreams.instance.playerControllerFactory
|
||||
.forEach((playKey, controller) {
|
||||
controller._setPlayerState(PlayerState.paused);
|
||||
});
|
||||
}
|
||||
return isAllPlayersPaused;
|
||||
}
|
||||
|
||||
/// Sets [_shouldRefresh] flag with provided boolean parameter.
|
||||
void _setRefresh(bool refresh) {
|
||||
_shouldRefresh = refresh;
|
||||
}
|
||||
|
||||
/// Sets [_shouldRefresh] flag with provided boolean parameter.
|
||||
void setRefresh(bool refresh) {
|
||||
_shouldRefresh = refresh;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void notifyListeners() {
|
||||
if (_isDisposed) return;
|
||||
super.notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is PlayerController && other.playerKey == playerKey;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => super.hashCode; //ignore: unnecessary_overrides
|
||||
}
|
||||
349
audio_waveforms/lib/src/controllers/recorder_controller.dart
Normal file
|
|
@ -0,0 +1,349 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io' show Platform;
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '/src/base/utils.dart';
|
||||
import '../base/constants.dart';
|
||||
import '../base/platform_streams.dart';
|
||||
import '../models/recorder_settings.dart';
|
||||
import 'player_controller.dart';
|
||||
|
||||
// ignore_for_file: deprecated_member_use_from_same_package
|
||||
class RecorderController extends ChangeNotifier {
|
||||
/// A class having controls for recording audio and other useful handlers.
|
||||
RecorderController() {
|
||||
if (!_platformStream.isInitialised) {
|
||||
_platformStream.init();
|
||||
}
|
||||
_amplitudeStreamSubscription =
|
||||
PlatformStreams.instance.onAmplitude.listen(_updateOnNewAmplitude);
|
||||
_currentDurationStreamSubscription =
|
||||
_platformStream.onCurrentDuration.listen((duration) {
|
||||
_elapsedDuration = duration;
|
||||
});
|
||||
}
|
||||
|
||||
final _platformStream = PlatformStreams.instance;
|
||||
|
||||
final List<double> _waveData = [];
|
||||
|
||||
/// Current list of scaled waves. For IOS, this list contains normalised
|
||||
/// peak power and for Android, this list contains normalised peak
|
||||
/// amplitude.
|
||||
///
|
||||
/// Values are between 0.0 to 1.0.
|
||||
List<double> get waveData => _waveData;
|
||||
|
||||
RecorderState _recorderState = RecorderState.stopped;
|
||||
|
||||
/// Provides current state of the [recorder]
|
||||
RecorderState get recorderState => _recorderState;
|
||||
|
||||
bool _isRecording = false;
|
||||
|
||||
/// A boolean check for state of recording. It is true when recording
|
||||
/// is on going otherwise false.
|
||||
bool get isRecording => _isRecording;
|
||||
|
||||
bool _shouldRefresh = true;
|
||||
|
||||
bool get shouldRefresh => _shouldRefresh;
|
||||
|
||||
bool _hasPermission = false;
|
||||
|
||||
/// A boolean to check for microphone permission status. It is true when
|
||||
/// user has provided the microphone permission otherwise false.
|
||||
bool get hasPermission => _hasPermission;
|
||||
|
||||
/// IOS only.
|
||||
///
|
||||
/// Overrides AVAudioSession settings with
|
||||
/// ```
|
||||
/// AVAudioSession.Category: .playAndRecord
|
||||
/// AVAudioSession.CategoryOptions: [.defaultToSpeaker, .allowBluetooth]
|
||||
/// ```
|
||||
/// You may use your implementation to set your preferred configurations.
|
||||
/// Changes to this property will only take effect after you call [record].
|
||||
///
|
||||
/// **Important**-: If you set this property to false, you will be responsible
|
||||
/// for the setting current configuration. Failed to do so may result in
|
||||
/// audio not being recorded and waves not generating.
|
||||
///
|
||||
/// Defaults to true.
|
||||
bool overrideAudioSession = true;
|
||||
|
||||
bool get shouldClearLabels => _shouldClearLabels;
|
||||
|
||||
bool _shouldClearLabels = false;
|
||||
|
||||
bool _isDisposed = false;
|
||||
|
||||
/// Provides currently recorded audio duration. Use [onCurrentDuration]
|
||||
/// stream to get latest events duration.
|
||||
Duration get elapsedDuration => _elapsedDuration;
|
||||
|
||||
Duration _elapsedDuration = Duration.zero;
|
||||
|
||||
/// Provides duration of recorded audio file when recording has been stopped.
|
||||
/// Until recording has been stopped, this duration will be
|
||||
/// zero(Duration.zero). Also, once new recording is started this duration
|
||||
/// will be reset to zero.
|
||||
Duration get recordedDuration => _recordedDuration;
|
||||
|
||||
Duration _recordedDuration = Duration.zero;
|
||||
|
||||
final ValueNotifier<int> _currentScrolledDuration = ValueNotifier(0);
|
||||
|
||||
/// A stream to get current duration of currently recording audio file.
|
||||
/// Events are emitted as soon it is available from platform.
|
||||
Stream<Duration> get onCurrentDuration => _platformStream.onCurrentDuration;
|
||||
|
||||
final StreamController<RecorderState> _recorderStateController =
|
||||
StreamController.broadcast();
|
||||
|
||||
final StreamController<Duration> _recordedFileDurationController =
|
||||
StreamController.broadcast();
|
||||
|
||||
/// A Stream to monitor change in RecorderState. Events are emitted whenever
|
||||
/// there is change in the RecorderState.
|
||||
Stream<RecorderState> get onRecorderStateChanged =>
|
||||
_recorderStateController.stream;
|
||||
|
||||
/// A stream to get duration of recording when audio recorder has
|
||||
/// been stopped. Events are only emitted if platform could extract the
|
||||
/// duration of audio file when recording is ended.
|
||||
Stream<Duration> get onRecordingEnded =>
|
||||
_recordedFileDurationController.stream;
|
||||
|
||||
/// A stream to get bytes while recording audio.
|
||||
Stream<Uint8List> get onAudioChunks => _platformStream.onRecordedBytes;
|
||||
|
||||
StreamSubscription<double>? _amplitudeStreamSubscription;
|
||||
StreamSubscription<Duration>? _currentDurationStreamSubscription;
|
||||
|
||||
/// A ValueNotifier which provides current position of scrolled waveform with
|
||||
/// respect to [middle line].
|
||||
///
|
||||
/// [shouldCalculateScrolledPosition] flag must be enabled to use it
|
||||
/// (available in [AudioWaveform] widget).
|
||||
///
|
||||
/// For better idea how duration is reported, enable duration labels and
|
||||
/// scroll toward middle line.
|
||||
///
|
||||
/// Reported duration is in [milliseconds].
|
||||
ValueNotifier<int> get currentScrolledDuration => _currentScrolledDuration;
|
||||
|
||||
/// Calls platform to start recording.
|
||||
///
|
||||
/// First, it checks for microphone permission, if permission
|
||||
/// isn't provided then function will complete with [RecorderState]
|
||||
/// set to [stopped].
|
||||
///
|
||||
/// [checkPermission] is used to check microphone permission. Follow
|
||||
/// it's documentation for more info.
|
||||
///
|
||||
/// Path parameter is optional and if not provided current datetime
|
||||
/// will be used for file name and default extension will be .m4a.
|
||||
///
|
||||
/// Below is the example format to save file with custom name and
|
||||
/// extension.
|
||||
///
|
||||
/// eg. /dir1/dir2/file-name.m4a
|
||||
///
|
||||
/// How recorder will behave for different RecorderState -:
|
||||
///
|
||||
/// 1. Paused-: If a recorder is paused, calling this function again
|
||||
/// will start recording again where it left of.
|
||||
///
|
||||
/// 2. Stopped -: If a recorder is stopped from previous recording and again
|
||||
/// this function is called then it will re-initialise the recorder.
|
||||
Future<void> record({
|
||||
String? path,
|
||||
RecorderSettings recorderSettings = const RecorderSettings(),
|
||||
}) async {
|
||||
if (!_recorderState.isRecording) {
|
||||
await checkPermission();
|
||||
if (_hasPermission) {
|
||||
if (Platform.isAndroid && _recorderState.isStopped) {
|
||||
await _initRecorder(
|
||||
path: path,
|
||||
recorderSettings: recorderSettings,
|
||||
);
|
||||
}
|
||||
if (_recorderState.isPaused) {
|
||||
_isRecording = await AudioWaveformsInterface.instance.resume();
|
||||
if (_isRecording) {
|
||||
_setRecorderState(RecorderState.recording);
|
||||
} else {
|
||||
throw "Failed to resume recording";
|
||||
}
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
if (Platform.isIOS) {
|
||||
_setRecorderState(RecorderState.initialized);
|
||||
}
|
||||
if (_recorderState.isInitialized) {
|
||||
_isRecording = await AudioWaveformsInterface.instance.record(
|
||||
recorderSetting: recorderSettings,
|
||||
path: path,
|
||||
overrideAudioSession: overrideAudioSession,
|
||||
);
|
||||
if (_isRecording) {
|
||||
_setRecorderState(RecorderState.recording);
|
||||
} else {
|
||||
throw "Failed to start recording";
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
} else {
|
||||
_setRecorderState(RecorderState.stopped);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialises recorder for android platform.
|
||||
Future<void> _initRecorder({
|
||||
String? path,
|
||||
required RecorderSettings recorderSettings,
|
||||
}) async {
|
||||
final initialized = await AudioWaveformsInterface.instance.initRecorder(
|
||||
path: path,
|
||||
recorderSettings: recorderSettings,
|
||||
);
|
||||
if (initialized) {
|
||||
_setRecorderState(RecorderState.initialized);
|
||||
} else {
|
||||
throw "Failed to initialize recorder";
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Checks for microphone permission and return true if permission was
|
||||
/// provided otherwise returns false.
|
||||
///
|
||||
/// If this is first time check for microphone permission then it
|
||||
/// opens a platform dialog with description string which was set
|
||||
/// during initial set up.
|
||||
///
|
||||
/// This method is also called during [record].
|
||||
Future<bool> checkPermission() async {
|
||||
final result = await AudioWaveformsInterface.instance.checkPermission();
|
||||
if (result) {
|
||||
_hasPermission = result;
|
||||
}
|
||||
notifyListeners();
|
||||
return _hasPermission;
|
||||
}
|
||||
|
||||
/// Pauses the current recording. Call [record] to resume recording.
|
||||
Future<void> pause() async {
|
||||
if (_recorderState.isRecording) {
|
||||
_isRecording = (await AudioWaveformsInterface.instance.pause()) ?? true;
|
||||
if (_isRecording) {
|
||||
throw "Failed to pause recording";
|
||||
}
|
||||
_setRecorderState(RecorderState.paused);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Stops the current recording.
|
||||
///
|
||||
/// Resources are freed after calling this and file is saved and
|
||||
/// returns path where file is saved and it also clears waveform and
|
||||
/// resets to initial state. To change this behaviour, pass false to
|
||||
/// stop function's parameter and this will effectively will not
|
||||
/// call [reset] to clear waves.
|
||||
///
|
||||
/// When [callReset] is set to false it will require calling [reset] function
|
||||
/// manually else it will start showing waveforms from same place where it
|
||||
/// left of for previous recording.
|
||||
Future<String?> stop([bool callReset = true]) async {
|
||||
if (_recorderState.isRecording || _recorderState.isPaused) {
|
||||
final audioInfo = await AudioWaveformsInterface.instance.stop();
|
||||
_isRecording = false;
|
||||
if (audioInfo[Constants.resultDuration] != null) {
|
||||
final duration = audioInfo[Constants.resultDuration];
|
||||
|
||||
_recordedDuration = Duration(milliseconds: duration);
|
||||
_recordedFileDurationController.add(recordedDuration);
|
||||
}
|
||||
_elapsedDuration = Duration.zero;
|
||||
_setRecorderState(RecorderState.stopped);
|
||||
if (callReset) reset();
|
||||
return audioInfo[Constants.resultFilePath];
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Clears WaveData and labels from the list. This will effectively remove
|
||||
/// waves and labels from the UI.
|
||||
void reset() {
|
||||
_waveData.clear();
|
||||
_shouldClearLabels = true;
|
||||
refresh();
|
||||
}
|
||||
|
||||
/// Sets [shouldClearLabels] flag to false.
|
||||
void revertClearLabelCall() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_shouldClearLabels = false;
|
||||
notifyListeners();
|
||||
});
|
||||
}
|
||||
|
||||
void _updateOnNewAmplitude(double amplitude) {
|
||||
_waveData.add(amplitude);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Refreshes the waveform to the initial state after scrolling.
|
||||
void refresh() {
|
||||
_shouldRefresh = true;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Sets [_shouldRefresh] flag with provided boolean parameter.
|
||||
void setRefresh(bool refresh) {
|
||||
_shouldRefresh = refresh;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// A function internally used to set scrolled position to duration.
|
||||
void setScrolledPositionDuration(int duration) {
|
||||
_currentScrolledDuration.value = duration;
|
||||
}
|
||||
|
||||
void _setRecorderState(RecorderState state) {
|
||||
if (!_recorderStateController.isClosed) {
|
||||
_recorderStateController.add(state);
|
||||
_recorderState = state;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void notifyListeners() {
|
||||
if (_isDisposed) return;
|
||||
super.notifyListeners();
|
||||
}
|
||||
|
||||
/// Releases any resources taken by this recorder and with this
|
||||
/// controller is also disposed.
|
||||
@override
|
||||
void dispose() async {
|
||||
if (recorderState != RecorderState.stopped) await stop();
|
||||
_currentScrolledDuration.dispose();
|
||||
_recorderStateController.close();
|
||||
_recordedFileDurationController.close();
|
||||
_amplitudeStreamSubscription?.cancel();
|
||||
_currentDurationStreamSubscription?.cancel();
|
||||
_isDisposed = true;
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
part of 'player_controller.dart';
|
||||
|
||||
/// WaveformExtractionController is used for
|
||||
/// extraction of waveform data as List\<double\>,
|
||||
/// which can be used to show [AudioFileWaveforms]
|
||||
///
|
||||
/// It is used by [PlayerController] internally for handling data extraction,
|
||||
/// in case you only want data yourself you can use it as
|
||||
///
|
||||
/// ```dart
|
||||
/// final waveformExtraction = WaveformExtractionController();
|
||||
/// final waveformData = await waveformExtraction.extractWaveformData(path: '../audioFile.mp3');
|
||||
///
|
||||
/// ...
|
||||
///
|
||||
/// AudioFileWaveforms(
|
||||
/// ...
|
||||
/// waveformData: waveformData,
|
||||
/// ),
|
||||
/// ```dart
|
||||
class WaveformExtractionController {
|
||||
WaveformExtractionController() : _extractorKey = shortHash(UniqueKey());
|
||||
|
||||
WaveformExtractionController._(this._extractorKey);
|
||||
|
||||
final String _extractorKey;
|
||||
|
||||
final List<double> _waveformData = [];
|
||||
|
||||
/// This returns waveform data which can be used by [AudioFileWaveforms]
|
||||
/// to display waveforms.
|
||||
List<double> get waveformData => _waveformData.toList();
|
||||
|
||||
/// A stream to get current extracted waveform data. This stream will emit
|
||||
/// list of doubles which are waveform data point.
|
||||
Stream<List<double>> get onCurrentExtractedWaveformData =>
|
||||
PlatformStreams.instance.onCurrentExtractedWaveformData
|
||||
.filter(_extractorKey);
|
||||
|
||||
/// A stream to get current progress of waveform extraction.
|
||||
Stream<double> get onExtractionProgress =>
|
||||
PlatformStreams.instance.onExtractionProgress.filter(_extractorKey);
|
||||
|
||||
/// Extracts waveform data from provided audio file path.
|
||||
/// [noOfSamples] indicates number of extracted data points. This will
|
||||
/// determine number of bars in the waveform.
|
||||
///
|
||||
/// This function will decode whole audio file and will calculate RMS
|
||||
/// according to provided number of samples. So it may take a while to fully
|
||||
/// decode audio file, specifically on android.
|
||||
///
|
||||
/// For example, an audio file of 58 min and about 18 MB of size took about
|
||||
/// 4 minutes to decode on android while the same file took about 6-7 seconds
|
||||
/// on IOS.
|
||||
///
|
||||
/// Providing less number if sample doesn't make a difference because it
|
||||
/// still have to decode whole file.
|
||||
///
|
||||
/// [noOfSamples] defaults to 100 if both [noOfSamples] and [noOfSamplesPerSecond] are null.
|
||||
///
|
||||
/// [noOfSamplesPerSecond] can be used as an alternative to [noOfSamples] to specify
|
||||
/// the number of samples per second of audio. The actual [noOfSamples] will
|
||||
/// be calculated as: noOfSamplesPerSecond * durationInSeconds.
|
||||
/// This is useful when the full duration is not known in advance.
|
||||
///
|
||||
/// **Important**: Provide only ONE of [noOfSamples] OR [noOfSamplesPerSecond], not both.
|
||||
/// - To use fixed sample count: provide only [noOfSamples]
|
||||
/// - To use samples per second: provide only [noOfSamplesPerSecond]
|
||||
/// - If both are null, defaults to [noOfSamples] = 100
|
||||
Future<List<double>> extractWaveformData({
|
||||
required String path,
|
||||
int? noOfSamples,
|
||||
int? noOfSamplesPerSecond,
|
||||
}) async {
|
||||
// Validate that user doesn't provide both parameters
|
||||
assert(
|
||||
!(noOfSamples != null && noOfSamplesPerSecond != null),
|
||||
'Cannot provide both noOfSamples and noOfSamplesPerSecond. '
|
||||
'Use noOfSamples for fixed count OR noOfSamplesPerSecond for dynamic calculation based on duration.',
|
||||
);
|
||||
|
||||
// Determine which sampling strategy to use
|
||||
final int actualNoOfSamples;
|
||||
if (noOfSamplesPerSecond != null) {
|
||||
// Get duration to calculate actual samples
|
||||
final duration = await AudioWaveformsInterface.instance.getDuration(
|
||||
_extractorKey,
|
||||
DurationType.max.index,
|
||||
);
|
||||
|
||||
if (duration != null && duration > 0) {
|
||||
actualNoOfSamples = (noOfSamplesPerSecond * (duration / 1000)).round();
|
||||
} else {
|
||||
// Fallback if duration unavailable
|
||||
actualNoOfSamples = noOfSamplesPerSecond;
|
||||
}
|
||||
} else {
|
||||
// Use fixed sample count (default to 100 if not provided)
|
||||
actualNoOfSamples = noOfSamples ?? 100;
|
||||
}
|
||||
|
||||
return await AudioWaveformsInterface.instance.extractWaveformData(
|
||||
key: _extractorKey,
|
||||
path: path,
|
||||
noOfSamples: actualNoOfSamples,
|
||||
);
|
||||
}
|
||||
|
||||
/// Stops current waveform extraction, if any.
|
||||
Future<void> stopWaveformExtraction() async {
|
||||
return await AudioWaveformsInterface.instance
|
||||
.stopWaveformExtraction(_extractorKey);
|
||||
}
|
||||
}
|
||||
16
audio_waveforms/lib/src/models/android_encoder_settings.dart
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import '../../audio_waveforms.dart';
|
||||
|
||||
/// Class to specify encoder and output format settings for Android.
|
||||
class AndroidEncoderSettings {
|
||||
/// Constructor for AndroidEncoderSettings.
|
||||
///
|
||||
/// [androidEncoder] - Defines the encoder type for Android (default: AAC).
|
||||
/// [androidOutputFormat] - Specifies the output format for Android recordings (default: MPEG4).
|
||||
const AndroidEncoderSettings({
|
||||
this.androidEncoder = AndroidEncoder.aacLc,
|
||||
});
|
||||
|
||||
/// Encoder type for Android recordings.
|
||||
/// Default is aacLc.
|
||||
final AndroidEncoder androidEncoder;
|
||||
}
|
||||
40
audio_waveforms/lib/src/models/ios_encoder_setting.dart
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import '../base/utils.dart';
|
||||
|
||||
/// Class to configure encoder settings for iOS recordings.
|
||||
class IosEncoderSetting {
|
||||
/// Constructor for IosEncoderSetting.
|
||||
///
|
||||
/// [iosEncoder] - Defines the encoder type for iOS (default: MPEG4 AAC).
|
||||
/// [linearPCMBitDepth] - Specifies the bit depth per sample (optional).
|
||||
/// [linearPCMIsBigEndian] - Specifies byte order for PCM format (optional).
|
||||
/// [linearPCMIsFloat] - Determines if PCM format uses floating-point samples (optional).
|
||||
const IosEncoderSetting({
|
||||
this.iosEncoder = IosEncoder.kAudioFormatMPEG4AAC,
|
||||
this.linearPCMBitDepth,
|
||||
this.linearPCMIsBigEndian,
|
||||
this.linearPCMIsFloat,
|
||||
});
|
||||
|
||||
/// Encoder type for iOS recordings.
|
||||
/// Default is MPEG4 AAC.
|
||||
final IosEncoder iosEncoder;
|
||||
|
||||
/// Specifies the bit depth per sample.
|
||||
///
|
||||
/// Higher values (e.g., 24 or 32) improve audio quality but increase file size.
|
||||
/// Supported values: 8, 16, 24, 32.
|
||||
/// Default value 16 bits.
|
||||
final int? linearPCMBitDepth;
|
||||
|
||||
/// Specifies the byte order:
|
||||
/// false: Little-endian (least significant byte first).
|
||||
/// true: Big-endian (most significant byte first).
|
||||
/// Default value false.
|
||||
final bool? linearPCMIsBigEndian;
|
||||
|
||||
/// Determines whether audio samples are stored as floating-point values:
|
||||
/// false: Integer format.
|
||||
/// true: Floating-point format, often used in scientific or high-precision audio processing.
|
||||
/// Default value false.
|
||||
final bool? linearPCMIsFloat;
|
||||
}
|
||||
60
audio_waveforms/lib/src/models/recorder_settings.dart
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import '../base/constants.dart';
|
||||
import 'android_encoder_settings.dart';
|
||||
import 'ios_encoder_setting.dart';
|
||||
|
||||
/// Class to configure audio recording settings for Android and iOS.
|
||||
class RecorderSettings {
|
||||
/// Constructor for RecorderSettings.
|
||||
///
|
||||
/// [androidEncoderSettings] - Specifies encoder settings for Android devices.
|
||||
/// [iosEncoderSettings] - Specifies encoder settings for iOS devices.
|
||||
/// [sampleRate] - Defines the sampling rate for audio recording (default: 44100 Hz).
|
||||
/// [bitRate] - Specifies the bit rate for encoding audio (optional).
|
||||
const RecorderSettings({
|
||||
this.androidEncoderSettings = const AndroidEncoderSettings(),
|
||||
this.iosEncoderSettings = const IosEncoderSetting(),
|
||||
this.sampleRate = 44100,
|
||||
this.bitRate = 128000,
|
||||
});
|
||||
|
||||
/// Encoder settings for Android devices.
|
||||
final AndroidEncoderSettings androidEncoderSettings;
|
||||
|
||||
/// Encoder settings for iOS devices.
|
||||
final IosEncoderSetting iosEncoderSettings;
|
||||
|
||||
/// Sampling rate for audio recording in Hertz (Hz).
|
||||
/// Default is 44100 Hz.
|
||||
final int sampleRate;
|
||||
|
||||
/// Bit rate for encoding audio in bits per second (bps).
|
||||
/// Higher values provide better quality but larger file sizes.
|
||||
final int bitRate;
|
||||
|
||||
/// Converts the RecorderSettings instance to a JSON map for iOS.
|
||||
Map<String, dynamic> iosToJson({
|
||||
String? path,
|
||||
bool useLegacyNormalization = false,
|
||||
bool overrideAudioSession = true,
|
||||
}) =>
|
||||
{
|
||||
Constants.path: path,
|
||||
Constants.encoder: iosEncoderSettings.iosEncoder.index,
|
||||
Constants.sampleRate: sampleRate,
|
||||
Constants.bitRate: bitRate,
|
||||
Constants.useLegacyNormalization: useLegacyNormalization,
|
||||
Constants.overrideAudioSession: overrideAudioSession,
|
||||
Constants.linearPCMBitDepth: iosEncoderSettings.linearPCMBitDepth,
|
||||
Constants.linearPCMIsBigEndian: iosEncoderSettings.linearPCMIsBigEndian,
|
||||
Constants.linearPCMIsFloat: iosEncoderSettings.linearPCMIsFloat,
|
||||
};
|
||||
|
||||
/// Converts the RecorderSettings instance to a JSON map for Android.
|
||||
Map<String, dynamic> androidToJson({String? path}) => {
|
||||
Constants.path: path,
|
||||
Constants.encoder:
|
||||
androidEncoderSettings.androidEncoder.toNativeFormat(),
|
||||
Constants.sampleRate: sampleRate,
|
||||
Constants.bitRate: bitRate,
|
||||
};
|
||||
}
|
||||
103
audio_waveforms/lib/src/painters/player_wave_painter.dart
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../audio_waveforms.dart';
|
||||
|
||||
class PlayerWavePainter extends CustomPainter {
|
||||
PlayerWavePainter({
|
||||
required this.waveformData,
|
||||
required this.animValue,
|
||||
required this.dragOffset,
|
||||
required this.totalBackDistance,
|
||||
required this.audioProgress,
|
||||
required this.pushBack,
|
||||
required this.callPushback,
|
||||
required this.scrollScale,
|
||||
required this.waveformType,
|
||||
required this.cachedAudioProgress,
|
||||
required this.playerWaveStyle,
|
||||
}) : fixedWavePaint = Paint()
|
||||
..color = playerWaveStyle.fixedWaveColor
|
||||
..strokeWidth = playerWaveStyle.waveThickness
|
||||
..strokeCap = playerWaveStyle.waveCap
|
||||
..shader = playerWaveStyle.fixedWaveGradient,
|
||||
liveWavePaint = Paint()
|
||||
..color = playerWaveStyle.liveWaveColor
|
||||
..strokeWidth = playerWaveStyle.waveThickness
|
||||
..strokeCap = playerWaveStyle.waveCap
|
||||
..shader = playerWaveStyle.liveWaveGradient,
|
||||
emptySpace = playerWaveStyle.spacing,
|
||||
middleLinePaint = Paint()
|
||||
..color = playerWaveStyle.seekLineColor
|
||||
..strokeWidth = playerWaveStyle.seekLineThickness;
|
||||
|
||||
final List<double> waveformData;
|
||||
final double animValue;
|
||||
final Offset totalBackDistance;
|
||||
final Offset dragOffset;
|
||||
final double audioProgress;
|
||||
final VoidCallback pushBack;
|
||||
final bool callPushback;
|
||||
final double emptySpace;
|
||||
final double scrollScale;
|
||||
final WaveformType waveformType;
|
||||
|
||||
final PlayerWaveStyle playerWaveStyle;
|
||||
|
||||
Paint fixedWavePaint;
|
||||
Paint liveWavePaint;
|
||||
Paint middleLinePaint;
|
||||
double cachedAudioProgress;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
_drawWave(size, canvas);
|
||||
if (playerWaveStyle.showSeekLine && waveformType.isLong) {
|
||||
_drawMiddleLine(size, canvas);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(PlayerWavePainter oldDelegate) => true;
|
||||
|
||||
void _drawMiddleLine(Size size, Canvas canvas) {
|
||||
canvas.drawLine(
|
||||
Offset(size.width / 2, 0),
|
||||
Offset(size.width / 2, size.height),
|
||||
fixedWavePaint
|
||||
..color = playerWaveStyle.seekLineColor
|
||||
..strokeWidth = playerWaveStyle.seekLineThickness,
|
||||
);
|
||||
}
|
||||
|
||||
void _drawWave(Size size, Canvas canvas) {
|
||||
final length = waveformData.length;
|
||||
final halfWidth = size.width * 0.5;
|
||||
final halfHeight = size.height * 0.5;
|
||||
if (cachedAudioProgress != audioProgress) {
|
||||
pushBack();
|
||||
}
|
||||
for (int i = 0; i < length; i++) {
|
||||
final currentDragPointer = dragOffset.dx - totalBackDistance.dx;
|
||||
final waveWidth = i * playerWaveStyle.spacing;
|
||||
final dx = waveWidth +
|
||||
currentDragPointer +
|
||||
emptySpace +
|
||||
(waveformType.isFitWidth ? 0 : halfWidth);
|
||||
final waveHeight = (waveformData[i] * animValue) *
|
||||
playerWaveStyle.scaleFactor *
|
||||
scrollScale;
|
||||
final bottomDy =
|
||||
halfHeight + (playerWaveStyle.showBottom ? waveHeight : 0);
|
||||
final topDy = halfHeight + (playerWaveStyle.showTop ? -waveHeight : 0);
|
||||
|
||||
// Only draw waves which are in visible viewport.
|
||||
if (dx > 0 && dx < halfWidth * 2) {
|
||||
canvas.drawLine(
|
||||
Offset(dx, bottomDy),
|
||||
Offset(dx, topDy),
|
||||
i < audioProgress * length ? liveWavePaint : fixedWavePaint,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
283
audio_waveforms/lib/src/painters/recorder_wave_painter.dart
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '/src/base/label.dart';
|
||||
|
||||
///This will paint the waveform
|
||||
///
|
||||
///Addtional Information to play around
|
||||
///
|
||||
///this gives location of first wave from right to left when scrolling
|
||||
///
|
||||
///-totalBackDistance.dx + dragOffset.dx + (spacing * i)
|
||||
///
|
||||
///this gives location of first wave from left to right when scrolling
|
||||
///
|
||||
///-totalBackDistance.dx + dragOffset.dx
|
||||
class RecorderWavePainter extends CustomPainter {
|
||||
RecorderWavePainter({
|
||||
required this.waveData,
|
||||
required this.waveColor,
|
||||
required this.showMiddleLine,
|
||||
required this.spacing,
|
||||
required this.initialPosition,
|
||||
required this.showTop,
|
||||
required this.showBottom,
|
||||
required this.bottomPadding,
|
||||
required this.waveCap,
|
||||
required this.middleLineColor,
|
||||
required this.middleLineThickness,
|
||||
required this.totalCurrentBackDistance,
|
||||
required this.dragOffset,
|
||||
required this.waveThickness,
|
||||
required this.pushBack,
|
||||
required this.callPushback,
|
||||
required this.extendWaveform,
|
||||
required this.showHourInDuration,
|
||||
required this.showDurationLabel,
|
||||
required this.durationStyle,
|
||||
required this.durationLinesColor,
|
||||
required this.durationTextPadding,
|
||||
required this.durationLinesHeight,
|
||||
required this.labelSpacing,
|
||||
required this.gradient,
|
||||
required this.shouldClearLabels,
|
||||
required this.revertClearLabelCall,
|
||||
required this.setCurrentPositionDuration,
|
||||
required this.shouldCalculateScrolledPosition,
|
||||
required this.scaleFactor,
|
||||
required this.currentlyRecordedDuration,
|
||||
required this.labels,
|
||||
required this.isRtl,
|
||||
}) : _wavePaint = Paint()
|
||||
..color = waveColor
|
||||
..strokeWidth = waveThickness
|
||||
..strokeCap = waveCap,
|
||||
_linePaint = Paint()
|
||||
..color = middleLineColor
|
||||
..strokeWidth = middleLineThickness,
|
||||
_durationLinePaint = Paint()
|
||||
..strokeWidth = 3
|
||||
..color = durationLinesColor;
|
||||
|
||||
final List<double> waveData;
|
||||
final Color waveColor;
|
||||
final bool showMiddleLine;
|
||||
final double spacing;
|
||||
final double initialPosition;
|
||||
final bool showTop;
|
||||
final bool showBottom;
|
||||
final double bottomPadding;
|
||||
final StrokeCap waveCap;
|
||||
final Color middleLineColor;
|
||||
final double middleLineThickness;
|
||||
|
||||
/// This gives total current distance the waves have been pushed back
|
||||
final Offset totalCurrentBackDistance;
|
||||
final Offset dragOffset;
|
||||
final double waveThickness;
|
||||
final VoidCallback pushBack;
|
||||
final bool callPushback;
|
||||
final bool extendWaveform;
|
||||
final bool showDurationLabel;
|
||||
final bool showHourInDuration;
|
||||
final Paint _wavePaint;
|
||||
final Paint _linePaint;
|
||||
final Paint _durationLinePaint;
|
||||
final TextStyle durationStyle;
|
||||
final Color durationLinesColor;
|
||||
final double durationTextPadding;
|
||||
final double durationLinesHeight;
|
||||
final double labelSpacing;
|
||||
final Shader? gradient;
|
||||
final bool shouldClearLabels;
|
||||
final VoidCallback revertClearLabelCall;
|
||||
final ValueSetter<int> setCurrentPositionDuration;
|
||||
final bool shouldCalculateScrolledPosition;
|
||||
final double scaleFactor;
|
||||
final Duration currentlyRecordedDuration;
|
||||
final List<Label> labels;
|
||||
final bool isRtl;
|
||||
|
||||
static const int durationBuffer = 5;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
if (shouldClearLabels) {
|
||||
pushBack();
|
||||
revertClearLabelCall();
|
||||
}
|
||||
|
||||
// Wave gradient
|
||||
if (gradient != null) _waveGradient();
|
||||
|
||||
if (isRtl) {
|
||||
// For RTL: call pushBack when refresh is triggered (e.g., after scrolling)
|
||||
if (callPushback) {
|
||||
pushBack();
|
||||
}
|
||||
|
||||
for (var i = 0; i < waveData.length; i++) {
|
||||
_drawRtlWave(canvas, i, size);
|
||||
}
|
||||
} else {
|
||||
for (var i = 0; i < waveData.length; i++) {
|
||||
if (((spacing * i) + dragOffset.dx + spacing >
|
||||
size.width / (extendWaveform ? 1 : 2) +
|
||||
totalCurrentBackDistance.dx) &&
|
||||
callPushback) {
|
||||
pushBack();
|
||||
}
|
||||
|
||||
// draws waves
|
||||
_drawLtrWave(canvas, size, i);
|
||||
}
|
||||
}
|
||||
|
||||
// duration labels
|
||||
if (showDurationLabel) {
|
||||
_drawTextInRange(canvas, size);
|
||||
}
|
||||
|
||||
// middle line
|
||||
if (showMiddleLine) _drawMiddleLine(canvas, size);
|
||||
|
||||
// calculates scrolled position with respect to duration
|
||||
if (shouldCalculateScrolledPosition) {
|
||||
if (isRtl) {
|
||||
_setScrolledDurationRtl(size);
|
||||
} else {
|
||||
_setScrolledDuration(size);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(RecorderWavePainter oldDelegate) => true;
|
||||
|
||||
void _drawTextInRange(Canvas canvas, Size size) {
|
||||
for (var i = 0; i < labels.length; i++) {
|
||||
final label = labels[i];
|
||||
final content = label.content;
|
||||
Offset offset;
|
||||
if (isRtl) {
|
||||
// For RTL: labels follow wave positions (from right edge)
|
||||
final currentWaveformWidth = spacing * waveData.length;
|
||||
final labelWaveformWidth = label.offset.dx;
|
||||
final distanceFromRight = currentWaveformWidth - labelWaveformWidth;
|
||||
final labelX = size.width - distanceFromRight + dragOffset.dx;
|
||||
offset = Offset(labelX, label.offset.dy);
|
||||
} else {
|
||||
offset = label.offset - totalCurrentBackDistance + dragOffset;
|
||||
}
|
||||
final halfWidth = size.width * 0.5;
|
||||
|
||||
if (offset.dx > -halfWidth && offset.dx < halfWidth * 3) {
|
||||
canvas.drawLine(
|
||||
Offset(offset.dx + durationTextPadding, size.height),
|
||||
Offset(
|
||||
offset.dx + durationTextPadding,
|
||||
size.height + durationLinesHeight,
|
||||
),
|
||||
_durationLinePaint,
|
||||
);
|
||||
|
||||
final textSpan = TextSpan(
|
||||
text: content,
|
||||
style: durationStyle,
|
||||
);
|
||||
final textPainter = TextPainter(
|
||||
text: textSpan,
|
||||
textDirection: TextDirection.ltr,
|
||||
);
|
||||
textPainter.layout(minWidth: 0, maxWidth: halfWidth * 2);
|
||||
textPainter.paint(canvas, offset);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _drawMiddleLine(Canvas canvas, Size size) {
|
||||
final halfWidth = size.width * 0.5;
|
||||
canvas.drawLine(
|
||||
Offset(halfWidth, 0),
|
||||
Offset(halfWidth, size.height),
|
||||
_linePaint,
|
||||
);
|
||||
}
|
||||
|
||||
/// Draw wave for LTR direction
|
||||
void _drawLtrWave(Canvas canvas, Size size, int i) {
|
||||
final height = size.height;
|
||||
final dx = -totalCurrentBackDistance.dx +
|
||||
dragOffset.dx +
|
||||
(spacing * i) -
|
||||
initialPosition;
|
||||
final scaledWaveHeight = waveData[i] * scaleFactor;
|
||||
final upperDy = height - (showTop ? scaledWaveHeight : 0) - bottomPadding;
|
||||
final lowerDy =
|
||||
height + (showBottom ? scaledWaveHeight : 0) - bottomPadding;
|
||||
|
||||
// We will check here for starting position [dx]
|
||||
// to be grater than 0 and
|
||||
// the dx cannot be greater than canvas width
|
||||
// This condition will ensure that only visible
|
||||
// portions of waves are being drawn to user
|
||||
// and [dx > 0] will ensure only fully visible waves are drawn,
|
||||
// if any wave is half visible this will cut out that wave too.
|
||||
if (dx > 0 && dx < size.width) {
|
||||
canvas.drawLine(
|
||||
Offset(dx, upperDy),
|
||||
Offset(dx, lowerDy),
|
||||
_wavePaint,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw wave for RTL direction
|
||||
void _drawRtlWave(Canvas canvas, int i, Size size) {
|
||||
final height = size.height;
|
||||
// For RTL: newest wave at right edge, older waves move left
|
||||
// Wave position: right edge minus offset based on wave index from the end
|
||||
final dx = size.width - (spacing * (waveData.length - i)) + dragOffset.dx;
|
||||
|
||||
final scaledWaveHeight = waveData[i] * scaleFactor;
|
||||
final upperDy = height - (showTop ? scaledWaveHeight : 0) - bottomPadding;
|
||||
final lowerDy =
|
||||
height + (showBottom ? scaledWaveHeight : 0) - bottomPadding;
|
||||
|
||||
// We will check here for starting position [dx]
|
||||
// to be less than size.width and
|
||||
// the dx cannot be less than 0
|
||||
// This condition will ensure that only visible
|
||||
// portions of waves are being drawn to user
|
||||
// and [dx < size.width] will ensure only fully visible waves are drawn,
|
||||
// if any wave is half visible this will cut out that wave too.
|
||||
if (dx < size.width && dx > 0) {
|
||||
canvas.drawLine(
|
||||
Offset(dx, upperDy),
|
||||
Offset(dx, lowerDy),
|
||||
_wavePaint,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _waveGradient() {
|
||||
_wavePaint.shader = gradient;
|
||||
}
|
||||
|
||||
void _setScrolledDuration(Size size) {
|
||||
setCurrentPositionDuration(
|
||||
(((-totalCurrentBackDistance.dx + dragOffset.dx - (size.width / 2)) /
|
||||
spacing) *
|
||||
1000)
|
||||
.abs()
|
||||
.toInt(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Set scrolled duration for RTL mode
|
||||
void _setScrolledDurationRtl(Size size) {
|
||||
setCurrentPositionDuration(
|
||||
(((-dragOffset.dx + (size.width / 2)) / spacing) * 1000).abs().toInt(),
|
||||
);
|
||||
}
|
||||
}
|
||||
29
audio_waveforms/pubspec.yaml
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
name: audio_waveforms
|
||||
description: A Flutter package that allow you to generate waveform while recording audio or from audio file.
|
||||
version: 2.0.2
|
||||
homepage: https://github.com/SimformSolutionsPvtLtd/audio_waveforms
|
||||
issue_tracker: https://github.com/SimformSolutionsPvtLtd/audio_waveforms/issues
|
||||
|
||||
environment:
|
||||
sdk: ">=3.0.0 <4.0.0"
|
||||
flutter: ">=3.10.0"
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^6.0.0
|
||||
|
||||
|
||||
flutter:
|
||||
plugin:
|
||||
platforms:
|
||||
android:
|
||||
package: com.simform.audio_waveforms
|
||||
pluginClass: AudioWaveformsPlugin
|
||||
ios:
|
||||
pluginClass: AudioWaveformsPlugin
|
||||
|
||||
21
avatar_maker/LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2024 RoadTripMoustache
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
15
avatar_maker/assets/icons/accessories.svg
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||
<svg version="1.1" id="accessories" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px"
|
||||
viewBox="0 0 512 512" height="512" width="512" style="enable-background:new 0 0 512 512;"
|
||||
xml:space="preserve">
|
||||
<path d="M497,208.285h-15.616c-4.312-10.76-10.158-18.468-15.463-23.773c-13.429-13.431-28.593-17.138-32.92-17.953
|
||||
c-13.158-2.481-34.251-3.131-55.481-2.976c-0.063-0.001-0.124-0.01-0.187-0.01c-0.001,0-0.002,0-0.003,0l-238.324,0.048
|
||||
c-0.031,0-0.06,0.004-0.09,0.005c-22.688-0.281-45.843,0.279-59.917,2.933c-4.327,0.815-19.491,4.522-32.92,17.953
|
||||
c-5.305,5.306-11.152,13.013-15.463,23.773H15c-8.284,0-15,6.716-15,15s6.716,15,15,15h9.366c-0.097,1.931-0.15,3.913-0.15,5.952
|
||||
c0,64.701,43.484,99.008,83.887,103.503c4.212,0.469,8.424,0.7,12.62,0.7c27.416-0.001,54.09-9.878,74.692-27.969
|
||||
c16.256-14.273,35.162-39.473,42.698-82.187h35.774c7.536,42.713,26.442,67.914,42.698,82.187
|
||||
c20.604,18.092,47.274,27.969,74.692,27.969c4.194-0.001,8.41-0.231,12.62-0.7c40.402-4.495,83.887-38.802,83.887-103.503
|
||||
c0-2.039-0.053-4.021-0.15-5.952H497c8.284,0,15-6.716,15-15S505.284,208.285,497,208.285z M270.74,208.285h-29.48
|
||||
c0.069-1.759,0.128-3.533,0.162-5.339c0.06-3.191-0.314-6.325-1.08-9.345l31.319-0.006c-0.767,3.022-1.143,6.158-1.082,9.352
|
||||
C270.612,204.752,270.671,206.526,270.74,208.285z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
20
avatar_maker/assets/icons/background.svg
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 48 48" id="a" xmlns="http://www.w3.org/2000/svg">
|
||||
|
||||
<defs>
|
||||
|
||||
<style>.d{fill:none;stroke:#000000;stroke-linecap:round;stroke-linejoin:round;}</style>
|
||||
|
||||
</defs>
|
||||
|
||||
<g id="b">
|
||||
|
||||
<path id="c" class="d" d="m40.5,5.5H7.5c-1.1046,0-2,.8954-2,2v33c0,1.1046.8954,2,2,2h33c1.1046,0,2-.8954,2-2V7.5c0-1.1046-.8954-2-2-2Z"/>
|
||||
|
||||
</g>
|
||||
|
||||
<circle class="d" cx="24" cy="24" r="15"/>
|
||||
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 532 B |
8
avatar_maker/assets/icons/eyebrows.svg
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||
<svg id="eyebrows" enable-background="new 0 0 512.005 512.005" height="512"
|
||||
viewBox="0 0 512.005 512.005" width="512" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="m510.421 189.479c-67.625-135.248-215.328-138.525-283.965-121.366l-215.074 53.518c-8.581 2.135-13.446 11.187-10.538 19.506l21.48 61.446c7.866 23.398 33.198 36.242 56.883 28.348.14-.047.279-.095.418-.146l160.701-58.896c82.569-30.262 177.475-16.353 247.677 36.299 5.564 4.174 13.27 3.973 18.611-.484 5.342-4.458 6.918-12.003 3.807-18.225z" />
|
||||
<path
|
||||
d="m457.004 274.187c-23.661 14.157-63.708 58.411-131.57 77.771-.155.034-.31.053-.465.092-.535.134-1.053.298-1.559.485-19.405 5.349-41.057 8.653-65.218 8.653-82.832 0-128.768-38.515-175.48-58.417-7.41-3.704-16.42-.701-20.125 6.708-3.705 7.41-.701 16.42 6.708 20.125 7.291 3.078 14.76 6.768 22.543 10.79l-24.353 36.53c-4.595 6.893-2.732 16.206 4.161 20.801 6.872 4.582 16.191 2.754 20.801-4.16l26.133-39.199c14.252 7.294 29.776 14.67 47.196 20.887l-11.188 44.752c-2.009 8.037 2.877 16.181 10.914 18.19 8.015 2.002 16.175-2.853 18.19-10.914l10.863-43.453c14.228 3.4 29.641 5.844 46.46 6.849v45.511c0 8.284 6.704 15 14.988 15s15-6.716 15-15v-45.313c15.71-.738 31.329-2.827 46.66-6.2l10.924 43.693c2.022 8.086 10.202 12.912 18.19 10.914 8.037-2.009 12.923-10.153 10.914-18.19l-11.103-44.411c16.493-5.524 32.471-12.581 47.672-21.066l25.409 38.114c4.616 6.924 13.94 8.735 20.801 4.16 6.893-4.595 8.756-13.908 4.161-20.801l-24.997-37.496c5.881-4.181 4.22-3.089 55.37-41.403 6.627-4.971 7.971-14.373 3-21-4.97-6.629-14.371-7.973-21-3.002z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
12
avatar_maker/assets/icons/eyes.svg
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||
<svg version="1.1" id="eyes" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px"
|
||||
viewBox="0 0 461.312 461.312" style="enable-background:new 0 0 461.312 461.312;"
|
||||
xml:space="preserve" height="512" width="512">
|
||||
<path d="M230.656,156.416c-40.96,0-74.24,33.28-74.24,74.24s33.28,74.24,74.24,74.24s74.24-33.28,74.24-74.24
|
||||
S271.616,156.416,230.656,156.416z M225.024,208.64c-9.216,0-16.896,7.68-16.896,16.896h-24.576
|
||||
c0.512-23.04,18.944-41.472,41.472-41.472V208.64z" />
|
||||
<path d="M455.936,215.296c-25.088-31.232-114.688-133.12-225.28-133.12S30.464,184.064,5.376,215.296
|
||||
c-7.168,8.704-7.168,21.504,0,30.72c25.088,31.232,114.688,133.12,225.28,133.12s200.192-101.888,225.28-133.12
|
||||
C463.104,237.312,463.104,224.512,455.936,215.296z M230.656,338.176c-59.392,0-107.52-48.128-107.52-107.52
|
||||
s48.128-107.52,107.52-107.52s107.52,48.128,107.52,107.52S290.048,338.176,230.656,338.176z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 946 B |
10
avatar_maker/assets/icons/facial_hair.svg
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||
<svg id="facial_hair" enable-background="new 0 0 512 512" height="512" viewBox="0 0 512 512"
|
||||
width="512" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="m226 264h60c41.353 0 75-33.647 75-75 0-.558-.146-1.078-.159-1.633-4.885.189-9.688.432-14.841.432-35.391 0-66.021-6.738-90-19.629-23.979 12.891-54.609 19.629-90 19.629-5.153 0-9.955-.245-14.841-.434-.013.557-.159 1.077-.159 1.635 0 41.353 33.647 75 75 75z" />
|
||||
<path
|
||||
d="m30 152.687v57.905c0 135.249 91.835 255.073 221.884 292.236l4.116 1.172 4.116-1.172c130.049-37.163 221.884-156.987 221.884-292.236v-57.902c-21.588 15.333-51.821 27.257-91.383 32.371.051 1.335.383 2.589.383 3.939 0 57.891-47.109 105-105 105h-60c-57.891 0-105-47.109-105-105 0-1.351.331-2.607.383-3.944-39.566-5.123-69.8-17.058-91.383-32.369z" />
|
||||
<path
|
||||
d="m381.7 38.099c-22.2-15.599-43.2-30.099-65.7-30.099-28.5 0-47.999 15.399-60 25.6-12.001-10.201-31.8-25.6-60-25.6-21.899 0-42.599 14.5-64.799 29.799-42.902 29.702-72.7 45.3-106.3 15.3l-24.901-22.2c1.844 2.999-10.938 126.899 166 126.899 38.101 0 69.901-9 90-25.499 20.099 16.5 51.899 25.499 90 25.499 177.231 0 164.152-123.893 166-126.899l-24.901 22.2c-33.898 30.3-63.398 14.7-105.399-15z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
12
avatar_maker/assets/icons/facial_hair_color.svg
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||
<svg id="facial_hair_color" enable-background="new 0 0 512 512" height="512" viewBox="0 0 512 512"
|
||||
width="512" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="m255.999 242.603c-8.253 10.39-18.609 27.016-18.609 45.02 0 18.001 10.353 34.624 18.611 45.021 8.253-10.39 18.609-27.017 18.609-45.021 0-18.001-10.352-34.624-18.611-45.02z" />
|
||||
<path
|
||||
d="m313.094 183.962-36.278 36.715c11.434 13.622 27.795 38.138 27.795 66.945 0 40.892-32.963 73.152-38 77.841l-47.019 50.931c11.474 3.672 23.707 5.606 36.408 5.606 44.404 0 83.114-23.536 103.549-62.958 20.436-39.422 17.354-84.62-8.241-120.905z" />
|
||||
<path
|
||||
d="m235.497 354.938c-11.446-13.544-28.108-38.243-28.108-67.316 0-41.485 33.922-74.08 38.194-78.02l49.942-50.545-39.525-56.035-95.308 135.114c-25.596 36.285-28.677 81.483-8.241 120.905 9.456 18.241 22.83 33.072 38.865 43.753z" />
|
||||
<path
|
||||
d="m467 0h-422c-24.813 0-45 20.187-45 45v422c0 24.813 20.187 45 45 45h422c24.813 0 45-20.187 45-45v-422c0-24.813-20.187-45-45-45zm-407 75c0-8.284 6.716-15 15-15h90c8.284 0 15 6.716 15 15s-6.716 15-15 15h-90c-8.284 0-15-6.716-15-15zm326.184 297.848c-12.046 23.238-29.91 42.827-51.662 56.651-23.164 14.721-50.316 22.501-78.522 22.501s-55.358-7.78-78.521-22.501c-21.752-13.824-39.616-33.413-51.662-56.651-12.045-23.237-17.756-49.127-16.514-74.869 1.323-27.413 10.616-54.086 26.875-77.135l107.564-152.49c2.811-3.984 7.382-6.354 12.258-6.354s9.447 2.37 12.258 6.354l107.564 152.49c16.259 23.049 25.552 49.722 26.875 77.135 1.242 25.742-4.468 51.631-16.513 74.869z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
19
avatar_maker/assets/icons/hair.svg
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||
<svg version="1.1" id="hairs" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px" width="512"
|
||||
height="512" viewBox="0 0 377.917 377.917" style="enable-background:new 0 0 512 512;"
|
||||
xml:space="preserve">
|
||||
<path d="M58.307,351.001c-8.764,1.739-13.738-47.06-13.738-47.06s-7.607,1.18-7.596,21.055
|
||||
c-22.502-5.57-12.862-91.187-12.862-91.187s-13.44-2.347-23.676,5.546c-3.789-7.893,18.415-17.818,18.415-17.818
|
||||
s-5.261-4.1-14.626-2.056c-1.733-5.838,15.508-9.937,15.508-9.937s-5.547-5.547-16.658-4.683
|
||||
c1.471-7.298,15.501-7.602,15.501-7.602s8.448-42.073,7.876-48.805c-0.572-6.72,1.472-16.955-19.285,13.452
|
||||
c-0.875-69.257,56.4-69.257,72.772-84.466c16.359-15.186,48.092-22.911,54.654-29.222c6.568-6.317,23.092-21.906,35.948-21.334
|
||||
c-14.316,11.111-26.022,25.433-13.744,21.632c57.306-19.285,102.59-11.98,125.395,0.584c22.794,12.57,80.969,56.43,92.366,119.836
|
||||
c11.396,63.412-9.493,69.263-14.024,79.795c23.378,57.557-29.52,99.945-29.52,99.945s4.671-89.739-16.955-101.416
|
||||
c-6.131-2.92-2.056,21.334-11.969,29.52c0.863-29.801-10.241-56.108-10.241-56.108s3.503,19.29-2.919,27.757
|
||||
c-1.156-21.043-11.105-35.359-11.105-35.359s5.839,21.626-3.503,27.768c-1.156-19.605-11.969-37.133-11.969-37.133
|
||||
s-2.324,8.034-1.472,28.363c-5.547-11.7-48.227-68.393-57.008-52.909c-8.764,15.496,48.519,55.537,48.799,78.039
|
||||
c-19.268-19.863-93.802-85.354-104.037-90.317c-10.235-4.963,28.649,54.655,54.059,73.069
|
||||
c-20.739-2.347-47.929-29.811-47.929-29.811s17.241,31.563,30.098,42.375c-13.154,6.423-42.091-32.146-48.221-40.612
|
||||
c0.583,9.646,0.187,24.02,25.619,42.435c-24.259,7.882-49.283-57.954-52.5-51.204c-7.894,33.898,9.336,57.872,32.725,77.162
|
||||
c-25.421-0.304-64.306-47.935-64.306-47.935s-11.099,40.916,9.073,49.674C52.749,284.675,53.333,331.711,58.307,351.001z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
8
avatar_maker/assets/icons/hair_color.svg
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||
<svg id="hair_color" enable-background="new 0 0 512 512" height="512" viewBox="0 0 512 512"
|
||||
width="512" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="m25.605 507.605 57.528-57.528c10.472 20.988 24.192 40.459 41.261 57.528 2.813 2.813 6.622 4.395 10.606 4.395h181c3.984 0 7.793-1.582 10.605-4.395 41.529-41.528 64.395-98.738 64.395-157.463v-5.142c0-8.291-6.709-15-15-15h-301c-8.291 0-15 6.709-15 15v5.142c0 23.412 3.787 48.208 10.789 69.859l-66.394 66.394c-5.859 5.859-5.859 15.352 0 21.211s15.351 5.859 21.21-.001z" />
|
||||
<path
|
||||
d="m150 300h211c0-32.999-27.001-60-60-60h-8.789l35.08-34.08 31.824 31.824v.015c17.539 17.509 46.113 17.535 63.633-.015 3.914-3.914 90.371-90.371 84.858-84.858 5.859-5.859 5.859-15.352 0-21.211s-15.352-5.859-21.211 0l-53.027 53.027-21.216-21.216 53.033-53.022c5.859-5.859 5.859-15.352 0-21.211s-15.352-5.859-21.211 0l-53.033 53.022-21.215-21.215 53.022-53.033c5.859-5.859 5.859-15.352 0-21.211s-15.352-5.859-21.211 0l-53.022 53.033-21.216-21.216 53.027-53.027c5.859-5.859 5.859-15.352 0-21.211s-15.352-5.859-21.211 0c-14.223 14.223-87.947 87.947-84.858 84.858-17.534 17.534-17.534 46.069 0 63.633l31.824 31.824-56.292 55.29h-53.789c-8.401 0-15-6.599-15-15s6.599-15 15-15h7.5c8.101 0 15-6.599 15-15s-6.899-15-15-15h-53.5c-32.999 0-60 27.001-60 60 0 32.377 26.431 60 60 60z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
5
avatar_maker/assets/icons/mouth.svg
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||
<svg height="512" viewBox="0 0 192 192" width="512" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="m164 85c-15.759-8.755-23.291-29-44-29-16 0-24 16-24 24 0-8-8-24-24-24-20.709 0-28.241 20.245-44 29-9 5-28 11-28 11 40 24 40 56 96 56s56-32 96-56c0 0-19-6-28-11zm-68 27c-25.581 0-56.266-10.218-79.811-14.32a344.922 344.922 0 0 0 39.811 1.32c23.984-.869 32-3 40-3s16.016 2.131 40 3a344.922 344.922 0 0 0 39.811-1.32c-23.545 4.102-54.23 14.32-79.811 14.32z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 516 B |
15
avatar_maker/assets/icons/noses.svg
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<title>nose_fill</title>
|
||||
<g id="页面-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="Part" transform="translate(-432.000000, -48.000000)" fill-rule="nonzero">
|
||||
<g id="nose_fill" transform="translate(432.000000, 48.000000)">
|
||||
<path d="M24,0 L24,24 L0,24 L0,0 L24,0 Z M12.5934901,23.257841 L12.5819402,23.2595131 L12.5108777,23.2950439 L12.4918791,23.2987469 L12.4918791,23.2987469 L12.4767152,23.2950439 L12.4056548,23.2595131 C12.3958229,23.2563662 12.3870493,23.2590235 12.3821421,23.2649074 L12.3780323,23.275831 L12.360941,23.7031097 L12.3658947,23.7234994 L12.3769048,23.7357139 L12.4804777,23.8096931 L12.4953491,23.8136134 L12.4953491,23.8136134 L12.5071152,23.8096931 L12.6106902,23.7357139 L12.6232938,23.7196733 L12.6232938,23.7196733 L12.6266527,23.7031097 L12.609561,23.275831 C12.6075724,23.2657013 12.6010112,23.2592993 12.5934901,23.257841 L12.5934901,23.257841 Z M12.8583906,23.1452862 L12.8445485,23.1473072 L12.6598443,23.2396597 L12.6498822,23.2499052 L12.6498822,23.2499052 L12.6471943,23.2611114 L12.6650943,23.6906389 L12.6699349,23.7034178 L12.6699349,23.7034178 L12.678386,23.7104931 L12.8793402,23.8032389 C12.8914285,23.8068999 12.9022333,23.8029875 12.9078286,23.7952264 L12.9118235,23.7811639 L12.8776777,23.1665331 C12.8752882,23.1545897 12.8674102,23.1470016 12.8583906,23.1452862 L12.8583906,23.1452862 Z M12.1430473,23.1473072 C12.1332178,23.1423925 12.1221763,23.1452606 12.1156365,23.1525954 L12.1099173,23.1665331 L12.0757714,23.7811639 C12.0751323,23.7926639 12.0828099,23.8018602 12.0926481,23.8045676 L12.108256,23.8032389 L12.3092106,23.7104931 L12.3186497,23.7024347 L12.3186497,23.7024347 L12.3225043,23.6906389 L12.340401,23.2611114 L12.337245,23.2485176 L12.337245,23.2485176 L12.3277531,23.2396597 L12.1430473,23.1473072 Z" id="MingCute" fill-rule="nonzero">
|
||||
</path>
|
||||
<path d="M7.27024,7.26736 C7.74507,5.09566 10,2 12,2 C14,2 16.255,5.09558 16.7298,7.26738 C16.9791,8.40783 17.3722,9.50838 17.8944,10.5528 C18.9068,12.5775 21,14.0425 21,16.5 C21,18.433 19.433,20 17.5,20 C16.9243,20 15.9804,19.6279 15.4472,19.8944 C15.0522,20.0919 14.7847,20.561 14.4901,20.8735 C13.9709,21.4242 13.2259,22 12,22 C10.7741,22 10.0291,21.4242 9.50991,20.8735 C9.21526,20.561 8.94781,20.0919 8.55279,19.8944 C8.01965,19.6279 7.07575,20 6.5,20 C4.567,20 3,18.433 3,16.5 C3,14.0425 5.09321,12.5775 6.10557,10.5528 C6.62778,9.50838 7.02088,8.40781 7.27024,7.26736 Z" id="路径" fill="#09244B">
|
||||
</path>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
15
avatar_maker/assets/icons/outfit.svg
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||
<svg version="1.1" id="outfit" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px"
|
||||
viewBox="0 0 295.526 295.526" style="enable-background:new 0 0 512 512;"
|
||||
xml:space="preserve" width="512" height="512">
|
||||
<path d="M147.763,44.074c12.801,0,23.858-8.162,27.83-20.169c-7.578,2.086-17.237,3.345-27.83,3.345
|
||||
c-10.592,0-20.251-1.259-27.828-3.345C123.905,35.911,134.961,44.074,147.763,44.074z" />
|
||||
<path d="M295.158,58.839c-0.608-1.706-1.873-3.109-3.521-3.873l-56.343-26.01c-11.985-4.06-24.195-7.267-36.524-9.611
|
||||
c-0.434-0.085-0.866-0.126-1.292-0.126c-3.052,0-5.785,2.107-6.465,5.197c-4.502,19.82-22.047,34.659-43.251,34.659
|
||||
c-21.203,0-38.749-14.838-43.25-34.659c-0.688-3.09-3.416-5.197-6.466-5.197c-0.426,0-0.858,0.041-1.292,0.126
|
||||
c-12.328,2.344-24.538,5.551-36.542,9.611L3.889,54.965c-1.658,0.764-2.932,2.167-3.511,3.873
|
||||
c-0.599,1.726-0.491,3.589,0.353,5.217l24.46,48.272c1.145,2.291,3.474,3.666,5.938,3.666c0.636,0,1.281-0.092,1.917-0.283
|
||||
l27.167-8.052v161.97c0,3.678,3.001,6.678,6.689,6.678h161.723c3.678,0,6.67-3.001,6.67-6.678V107.66l27.186,8.052
|
||||
c0.636,0.191,1.28,0.283,1.915,0.283c2.459,0,4.779-1.375,5.94-3.666l24.469-48.272C295.629,62.428,295.747,60.565,295.158,58.839z
|
||||
" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
12
avatar_maker/assets/icons/outfit_color.svg
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||
<svg id="outfit_color" enable-background="new 0 0 512 512" height="512" viewBox="0 0 512 512"
|
||||
width="512" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="m454.04 21.967c-14.165-14.166-33-21.967-53.033-21.967s-38.867 7.801-53.033 21.967l-31.82 31.82 106.066 106.066 31.82-31.82c14.166-14.166 21.967-33 21.967-53.033s-7.801-38.867-21.967-53.033z" />
|
||||
<path
|
||||
d="m284.334 64.393c-9.763-9.763-25.592-9.763-35.355 0s-9.763 25.592 0 35.355l127.279 127.279c9.763 9.763 25.592 9.763 35.355 0 9.763-9.763 9.763-25.592 0-35.355z" />
|
||||
<path
|
||||
d="m103.342 435.468c-16.31-.526-31.577-7.029-43.28-18.474l-19.057 34.938c-6.933 12.711-6.66 27.733.731 40.184 7.392 12.451 20.449 19.884 34.928 19.884s27.536-7.433 34.927-19.884c7.392-12.451 7.665-27.473.731-40.184z" />
|
||||
<path
|
||||
d="m224.231 159.853 31.819 31.819c5.858 5.858 5.858 15.355 0 21.213s-15.355 5.858-21.213 0l-31.819-31.819-21.213 21.213 31.819 31.819c5.858 5.858 5.858 15.355 0 21.213s-15.355 5.858-21.213 0l-31.819-31.819-21.213 21.213 31.819 31.819c5.858 5.858 5.858 15.355 0 21.213s-15.355 5.858-21.213 0l-31.763-31.763c-14.286 15.431-22.128 35.365-22.128 56.512v7.929l-15.355 15.355c-13.668 13.668-13.668 35.829 0 49.497 13.668 13.668 35.829 13.668 49.497 0l15.355-15.355h7.929c22.246 0 43.161-8.663 58.891-24.394l124.957-124.957-91.923-91.923z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
7
avatar_maker/assets/icons/skin.svg
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||
<svg id="skin" height="512" viewBox="0 0 24 24" width="512" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="m12 0h-10c-1.1 0-2 .9-2 2 0 .11.01.22.03.33l2.97 17.75v1.17c0 1.52 1.23 2.75 2.75 2.75h2.5c1.03 0 1.93-.57 2.4-1.41-.73-1.02-1.22-2.27-1.47-3.73l-.03.14h-4.3l-1.01-6h6.32l-.38 2.26.03-.03c.56-.55 1.33-.84 2.11-.79h.02l2.03-12.11c.02-.11.03-.22.03-.33 0-1.1-.9-2-2-2zm-1.51 11h-6.98l-.51-3h8zm.84-5h-8.66l-.67-4h10z" />
|
||||
<path
|
||||
d="m23.779 16.648c-.151-.151-.369-.229-.575-.217-1.348.084-2.495.398-3.447.913-.288-1.039-.843-2.072-1.678-3.085-.285-.347-.873-.347-1.158 0-.835 1.013-1.39 2.046-1.678 3.085-.952-.515-2.099-.83-3.447-.913-.209-.013-.423.066-.575.217s-.232.36-.22.574c.238 4.173 2.331 6.389 6.47 6.777.023 0 .105-.001.128-.003 4.068-.385 6.162-2.601 6.399-6.774.013-.213-.067-.422-.219-.574z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 883 B |
36
avatar_maker/lib/avatar_maker.dart
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
library avatar_maker;
|
||||
|
||||
// Controllers
|
||||
export "src/core/controllers/controllers.dart";
|
||||
|
||||
// Providers
|
||||
export "src/core/providers/providers.dart";
|
||||
|
||||
// Enumerations
|
||||
export "src/core/enums/property_items/accessories.dart";
|
||||
export "src/core/enums/property_items/background_style.dart";
|
||||
export "src/core/enums/property_items/eyebrows.dart";
|
||||
export "src/core/enums/property_items/eyes.dart";
|
||||
export "src/core/enums/property_items/facial_hair_colors.dart";
|
||||
export "src/core/enums/property_items/facial_hair_types.dart";
|
||||
export "src/core/enums/property_items/hair_colors.dart";
|
||||
export "src/core/enums/property_items/hair_styles.dart";
|
||||
export "src/core/enums/property_items/mouths.dart";
|
||||
export "src/core/enums/property_items/noses.dart";
|
||||
export "src/core/enums/property_items/outfit_colors.dart";
|
||||
export "src/core/enums/property_items/outfit_types.dart";
|
||||
export "src/core/enums/property_items/skin_colors.dart";
|
||||
export "src/core/enums/placeholders.dart";
|
||||
export "src/core/enums/property_category_ids.dart";
|
||||
|
||||
// Models
|
||||
export "src/core/models/customized_property_category.dart";
|
||||
export "src/core/models/property_item.dart";
|
||||
export "src/core/models/theme_data.dart";
|
||||
|
||||
// Widgets
|
||||
export "src/avatar/avatar_maker_avatar.dart";
|
||||
export "src/customizer/avatar_maker_customizer.dart";
|
||||
export "src/customizer/avatar_maker_random_widget.dart";
|
||||
export "src/customizer/avatar_maker_reset_widget.dart";
|
||||
export "src/customizer/avatar_maker_save_widget.dart";
|
||||
209
avatar_maker/lib/l10n/app_localizations.dart
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:intl/intl.dart' as intl;
|
||||
|
||||
import 'app_localizations_en.dart';
|
||||
import 'app_localizations_fr.dart';
|
||||
|
||||
/// Callers can lookup localized strings with an instance of AppLocalizations
|
||||
/// returned by `AppLocalizations.of(context)`.
|
||||
///
|
||||
/// Applications need to include `AppLocalizations.delegate()` in their app's
|
||||
/// `localizationDelegates` list, and the locales they support in the app's
|
||||
/// `supportedLocales` list. For example:
|
||||
///
|
||||
/// ```dart
|
||||
/// import 'l10n/app_localizations.dart';
|
||||
///
|
||||
/// return MaterialApp(
|
||||
/// localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
/// supportedLocales: AppLocalizations.supportedLocales,
|
||||
/// home: MyApplicationHome(),
|
||||
/// );
|
||||
/// ```
|
||||
///
|
||||
/// ## Update pubspec.yaml
|
||||
///
|
||||
/// Please make sure to update your pubspec.yaml to include the following
|
||||
/// packages:
|
||||
///
|
||||
/// ```yaml
|
||||
/// dependencies:
|
||||
/// # Internationalization support.
|
||||
/// flutter_localizations:
|
||||
/// sdk: flutter
|
||||
/// intl: any # Use the pinned version from flutter_localizations
|
||||
///
|
||||
/// # Rest of dependencies
|
||||
/// ```
|
||||
///
|
||||
/// ## iOS Applications
|
||||
///
|
||||
/// iOS applications define key application metadata, including supported
|
||||
/// locales, in an Info.plist file that is built into the application bundle.
|
||||
/// To configure the locales supported by your app, you’ll need to edit this
|
||||
/// file.
|
||||
///
|
||||
/// First, open your project’s ios/Runner.xcworkspace Xcode workspace file.
|
||||
/// Then, in the Project Navigator, open the Info.plist file under the Runner
|
||||
/// project’s Runner folder.
|
||||
///
|
||||
/// Next, select the Information Property List item, select Add Item from the
|
||||
/// Editor menu, then select Localizations from the pop-up menu.
|
||||
///
|
||||
/// Select and expand the newly-created Localizations item then, for each
|
||||
/// locale your application supports, add a new item and select the locale
|
||||
/// you wish to add from the pop-up menu in the Value field. This list should
|
||||
/// be consistent with the languages listed in the AppLocalizations.supportedLocales
|
||||
/// property.
|
||||
abstract class AppLocalizations {
|
||||
AppLocalizations(String locale)
|
||||
: localeName = intl.Intl.canonicalizedLocale(locale.toString());
|
||||
|
||||
final String localeName;
|
||||
|
||||
static AppLocalizations of(BuildContext context) {
|
||||
return Localizations.of<AppLocalizations>(context, AppLocalizations)!;
|
||||
}
|
||||
|
||||
static const LocalizationsDelegate<AppLocalizations> delegate =
|
||||
_AppLocalizationsDelegate();
|
||||
|
||||
/// A list of this localizations delegate along with the default localizations
|
||||
/// delegates.
|
||||
///
|
||||
/// Returns a list of localizations delegates containing this delegate along with
|
||||
/// GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate,
|
||||
/// and GlobalWidgetsLocalizations.delegate.
|
||||
///
|
||||
/// Additional delegates can be added by appending to this list in
|
||||
/// MaterialApp. This list does not have to be used at all if a custom list
|
||||
/// of delegates is preferred or required.
|
||||
static const List<LocalizationsDelegate<dynamic>> localizationsDelegates =
|
||||
<LocalizationsDelegate<dynamic>>[
|
||||
delegate,
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
];
|
||||
|
||||
/// A list of this localizations delegate's supported locales.
|
||||
static const List<Locale> supportedLocales = <Locale>[
|
||||
Locale('en'),
|
||||
Locale('fr')
|
||||
];
|
||||
|
||||
/// No description provided for @property_category_accessories.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Accessories'**
|
||||
String get property_category_accessories;
|
||||
|
||||
/// No description provided for @property_category_backgrounds.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Backgrounds'**
|
||||
String get property_category_backgrounds;
|
||||
|
||||
/// No description provided for @property_category_eyes.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Eyes'**
|
||||
String get property_category_eyes;
|
||||
|
||||
/// No description provided for @property_category_eyebrows.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Eyebrows'**
|
||||
String get property_category_eyebrows;
|
||||
|
||||
/// No description provided for @property_category_facial_hair_colors.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Facial Hair Colors'**
|
||||
String get property_category_facial_hair_colors;
|
||||
|
||||
/// No description provided for @property_category_facial_hair_types.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Facial Hairs'**
|
||||
String get property_category_facial_hair_types;
|
||||
|
||||
/// No description provided for @property_category_hair_colors.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Hair Colors'**
|
||||
String get property_category_hair_colors;
|
||||
|
||||
/// No description provided for @property_category_hairstyles.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Hairstyles'**
|
||||
String get property_category_hairstyles;
|
||||
|
||||
/// No description provided for @property_category_mouths.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Mouths'**
|
||||
String get property_category_mouths;
|
||||
|
||||
/// No description provided for @property_category_noses.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Noses'**
|
||||
String get property_category_noses;
|
||||
|
||||
/// No description provided for @property_category_outfit_colors.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Outfit Colors'**
|
||||
String get property_category_outfit_colors;
|
||||
|
||||
/// No description provided for @property_category_outfit_types.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Outfits'**
|
||||
String get property_category_outfit_types;
|
||||
|
||||
/// No description provided for @property_category_skins.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Skins'**
|
||||
String get property_category_skins;
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
extends LocalizationsDelegate<AppLocalizations> {
|
||||
const _AppLocalizationsDelegate();
|
||||
|
||||
@override
|
||||
Future<AppLocalizations> load(Locale locale) {
|
||||
return SynchronousFuture<AppLocalizations>(lookupAppLocalizations(locale));
|
||||
}
|
||||
|
||||
@override
|
||||
bool isSupported(Locale locale) =>
|
||||
<String>['en', 'fr'].contains(locale.languageCode);
|
||||
|
||||
@override
|
||||
bool shouldReload(_AppLocalizationsDelegate old) => false;
|
||||
}
|
||||
|
||||
AppLocalizations lookupAppLocalizations(Locale locale) {
|
||||
// Lookup logic when only language code is specified.
|
||||
switch (locale.languageCode) {
|
||||
case 'en':
|
||||
return AppLocalizationsEn();
|
||||
case 'fr':
|
||||
return AppLocalizationsFr();
|
||||
}
|
||||
|
||||
throw FlutterError(
|
||||
'AppLocalizations.delegate failed to load unsupported locale "$locale". This is likely '
|
||||
'an issue with the localizations generation tool. Please file an issue '
|
||||
'on GitHub with a reproducible sample app and the gen-l10n configuration '
|
||||
'that was used.');
|
||||
}
|
||||
45
avatar_maker/lib/l10n/app_localizations_en.dart
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import 'app_localizations.dart';
|
||||
|
||||
/// The translations for English (`en`).
|
||||
class AppLocalizationsEn extends AppLocalizations {
|
||||
AppLocalizationsEn([String locale = 'en']) : super(locale);
|
||||
|
||||
@override
|
||||
String get property_category_accessories => 'Accessories';
|
||||
|
||||
@override
|
||||
String get property_category_backgrounds => 'Backgrounds';
|
||||
|
||||
@override
|
||||
String get property_category_eyes => 'Eyes';
|
||||
|
||||
@override
|
||||
String get property_category_eyebrows => 'Eyebrows';
|
||||
|
||||
@override
|
||||
String get property_category_facial_hair_colors => 'Facial Hair Colors';
|
||||
|
||||
@override
|
||||
String get property_category_facial_hair_types => 'Facial Hairs';
|
||||
|
||||
@override
|
||||
String get property_category_hair_colors => 'Hair Colors';
|
||||
|
||||
@override
|
||||
String get property_category_hairstyles => 'Hairstyles';
|
||||
|
||||
@override
|
||||
String get property_category_mouths => 'Mouths';
|
||||
|
||||
@override
|
||||
String get property_category_noses => 'Noses';
|
||||
|
||||
@override
|
||||
String get property_category_outfit_colors => 'Outfit Colors';
|
||||
|
||||
@override
|
||||
String get property_category_outfit_types => 'Outfits';
|
||||
|
||||
@override
|
||||
String get property_category_skins => 'Skins';
|
||||
}
|
||||
46
avatar_maker/lib/l10n/app_localizations_fr.dart
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import 'app_localizations.dart';
|
||||
|
||||
/// The translations for French (`fr`).
|
||||
class AppLocalizationsFr extends AppLocalizations {
|
||||
AppLocalizationsFr([String locale = 'fr']) : super(locale);
|
||||
|
||||
@override
|
||||
String get property_category_accessories => 'Accessoires';
|
||||
|
||||
@override
|
||||
String get property_category_backgrounds => 'Arrière plans';
|
||||
|
||||
@override
|
||||
String get property_category_eyes => 'Yeux';
|
||||
|
||||
@override
|
||||
String get property_category_eyebrows => 'Sourcils';
|
||||
|
||||
@override
|
||||
String get property_category_facial_hair_colors =>
|
||||
'Couleurs de barbe et moustache';
|
||||
|
||||
@override
|
||||
String get property_category_facial_hair_types => 'Barbes et moustaches';
|
||||
|
||||
@override
|
||||
String get property_category_hair_colors => 'Couleurs cheveux';
|
||||
|
||||
@override
|
||||
String get property_category_hairstyles => 'Cheveux';
|
||||
|
||||
@override
|
||||
String get property_category_mouths => 'Bouches';
|
||||
|
||||
@override
|
||||
String get property_category_noses => 'Nez';
|
||||
|
||||
@override
|
||||
String get property_category_outfit_colors => 'Couleurs des tenues';
|
||||
|
||||
@override
|
||||
String get property_category_outfit_types => 'Tenues';
|
||||
|
||||
@override
|
||||
String get property_category_skins => 'Peaux';
|
||||
}
|
||||
63
avatar_maker/lib/src/avatar/avatar_maker_avatar.dart
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import "package:avatar_maker/src/core/controllers/controllers.dart";
|
||||
import "package:avatar_maker/src/core/models/customized_property_category.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter_svg/flutter_svg.dart";
|
||||
import "package:provider/provider.dart";
|
||||
|
||||
/// This widget renders the avatar of the user on screen.
|
||||
///
|
||||
/// Parameters :
|
||||
/// - [radius] : double - Radius of the circle which contains the avatar.
|
||||
/// Default : 75.0
|
||||
/// - [backgroundColor] : Color? - Background color to define for the circle.
|
||||
/// - [customizedPropertyCategories] : List<CustomizedPropertyCategory>? -
|
||||
/// List of the customized property categories you want to use. If a property
|
||||
/// category is not override, it will use the default one instead.
|
||||
/// - [locale] : Locale? - Locale to use. If nothing is defined, the default
|
||||
/// language will be used.
|
||||
class AvatarMakerAvatar extends StatelessWidget {
|
||||
final double radius;
|
||||
final Color? backgroundColor;
|
||||
final List<CustomizedPropertyCategory>? customizedPropertyCategories;
|
||||
final AvatarMakerController? controller;
|
||||
final Widget? progressIndicator;
|
||||
|
||||
AvatarMakerAvatar(
|
||||
{Key? key,
|
||||
this.radius = 75.0,
|
||||
this.backgroundColor,
|
||||
this.customizedPropertyCategories,
|
||||
this.progressIndicator,
|
||||
this.controller})
|
||||
: super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final avatarController = controller ??
|
||||
Provider.of<AvatarMakerController?>(context, listen: true) ??
|
||||
PersistentAvatarMakerController(customizedPropertyCategories: []);
|
||||
final loader = progressIndicator ?? CircularProgressIndicator.adaptive();
|
||||
|
||||
return CircleAvatar(
|
||||
radius: radius,
|
||||
backgroundColor: backgroundColor,
|
||||
child: ListenableBuilder(
|
||||
listenable: avatarController,
|
||||
builder: (context, child) {
|
||||
/// Returns an activity indicator if the initialization of the
|
||||
/// controller isn't fully done.
|
||||
if (avatarController.displayedAvatarSVG.isEmpty) {
|
||||
return loader;
|
||||
}
|
||||
return SvgPicture.string(
|
||||
avatarController.drawAvatarSVG(),
|
||||
height: radius * 1.6,
|
||||
semanticsLabel: "Your avatar",
|
||||
placeholderBuilder: (context) => Center(
|
||||
child: loader,
|
||||
),
|
||||
);
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,297 @@
|
|||
import "dart:math";
|
||||
|
||||
import "package:avatar_maker/l10n/app_localizations.dart";
|
||||
import "package:avatar_maker/src/core/enums/placeholders.dart";
|
||||
import "package:avatar_maker/src/core/enums/property_category_ids.dart";
|
||||
import "package:avatar_maker/src/core/enums/property_items/facial_hair_colors.dart";
|
||||
import "package:avatar_maker/src/core/enums/property_items/facial_hair_types.dart";
|
||||
import "package:avatar_maker/src/core/enums/property_items/hair_colors.dart";
|
||||
import "package:avatar_maker/src/core/enums/property_items/hair_styles.dart";
|
||||
import "package:avatar_maker/src/core/enums/property_items/outfit_colors.dart";
|
||||
import "package:avatar_maker/src/core/enums/property_items/outfit_types.dart";
|
||||
import "package:avatar_maker/src/core/models/customized_property_category.dart";
|
||||
import "package:avatar_maker/src/core/models/property_item.dart";
|
||||
import "package:avatar_maker/src/core/services/accessory_service.dart";
|
||||
import "package:avatar_maker/src/core/services/avatar_service.dart";
|
||||
import "package:avatar_maker/src/core/services/background_service.dart";
|
||||
import "package:avatar_maker/src/core/services/color_service.dart";
|
||||
import "package:avatar_maker/src/core/services/eye_service.dart";
|
||||
import "package:avatar_maker/src/core/services/eyebrow_service.dart";
|
||||
import "package:avatar_maker/src/core/services/mouth_service.dart";
|
||||
import "package:avatar_maker/src/core/services/nose_service.dart";
|
||||
import "package:avatar_maker/src/core/services/options_service.dart";
|
||||
import "package:avatar_maker/src/core/services/outfit_service.dart";
|
||||
import "package:avatar_maker/src/core/services/facial_hairs_service.dart";
|
||||
import "package:avatar_maker/src/core/services/hair_service.dart";
|
||||
import "package:avatar_maker/src/core/services/property_category_service.dart";
|
||||
import "package:avatar_maker/src/core/services/skin_service.dart";
|
||||
import "package:flutter/material.dart";
|
||||
|
||||
/// Abstract base class for Avatar Maker controllers
|
||||
///
|
||||
/// This class provides the core functionality for avatar customization
|
||||
/// without any persistence logic. Subclasses should implement the
|
||||
/// persistence strategy as needed.
|
||||
abstract class AvatarMakerController extends ChangeNotifier {
|
||||
/// Value which contains the svg code of the avatar to display
|
||||
String _displayedAvatarSVG = "";
|
||||
|
||||
String get displayedAvatarSVG => _displayedAvatarSVG;
|
||||
|
||||
/// List of all the property categories merged (the one given by the user with
|
||||
/// the default one stored in the code). Useful to get the default value from
|
||||
/// property categories which are not updatable.
|
||||
late final List<CustomizedPropertyCategory> propertyCategories;
|
||||
|
||||
/// List of all the property categories which are updatable.
|
||||
late final List<CustomizedPropertyCategory> displayedPropertyCategories;
|
||||
|
||||
/// Map of all the default selected options for all property category
|
||||
/// (displayed or not).
|
||||
late final Map<PropertyCategoryIds, PropertyItem> defaultSelectedOptions;
|
||||
|
||||
/// Localization instance to manage property categories displayed title.
|
||||
late final AppLocalizations l10n;
|
||||
|
||||
/// Stores the option selected by the user for each attribute
|
||||
/// where the key represents the Attribute
|
||||
/// and the value represents the index of the selected option.
|
||||
///
|
||||
/// Eg: selectedOptions["eyes"] gives the index of
|
||||
/// the kind of eyes picked by the user
|
||||
late Map<PropertyCategoryIds, PropertyItem> selectedOptions;
|
||||
|
||||
AvatarMakerController({
|
||||
List<CustomizedPropertyCategory>? customizedPropertyCategories,
|
||||
Map<PropertyCategoryIds, PropertyItem>? selectedOptions,
|
||||
Locale? locale,
|
||||
}) {
|
||||
// If no locale is provided, display the english text by default.
|
||||
if (locale == null) {
|
||||
locale = Locale("en");
|
||||
}
|
||||
this.l10n = lookupAppLocalizations(locale);
|
||||
this.propertyCategories = PropertyCategoryService.mergePropertyCategories(
|
||||
customizedPropertyCategories ?? [], l10n);
|
||||
this.displayedPropertyCategories = this
|
||||
.propertyCategories
|
||||
.where((category) => category.toDisplay)
|
||||
.toList();
|
||||
this.selectedOptions = selectedOptions ?? {};
|
||||
// Generate the default selected options based on the
|
||||
// [CustomizedPropertyCategory] list given to the constructor.
|
||||
this.defaultSelectedOptions = {
|
||||
for (var category in this.propertyCategories)
|
||||
category.id: category.defaultValue!
|
||||
};
|
||||
|
||||
// Initialize the controller
|
||||
initController();
|
||||
}
|
||||
|
||||
AvatarMakerController.fromSvg(
|
||||
{required String svg,
|
||||
List<CustomizedPropertyCategory>? customizedPropertyCategories,
|
||||
Locale? locale})
|
||||
: this(
|
||||
customizedPropertyCategories: customizedPropertyCategories,
|
||||
selectedOptions: AvatarService.extractPropertiesFromSvg(svg),
|
||||
locale: locale);
|
||||
|
||||
/// Initialize the controller by loading options and updating the preview
|
||||
Future<void> initController() async {
|
||||
selectedOptions = await getSelectedOptions();
|
||||
_displayedAvatarSVG = drawAvatarSVG();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Get the selected options. This method should be implemented by subclasses
|
||||
/// to provide the appropriate options retrieval strategy.
|
||||
Future<Map<PropertyCategoryIds, PropertyItem>> getSelectedOptions();
|
||||
|
||||
/// Update the displayed SVG with the new SVG given in parameter, or the one
|
||||
/// draw based on the selected options.
|
||||
void updatePreview({
|
||||
String newAvatarMakerSVG = "",
|
||||
}) {
|
||||
if (newAvatarMakerSVG.isEmpty) {
|
||||
newAvatarMakerSVG = drawAvatarSVG();
|
||||
}
|
||||
_displayedAvatarSVG = newAvatarMakerSVG;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Save the selected options and update the preview.
|
||||
///
|
||||
/// If avatar options are given in parameter, they will be loaded in
|
||||
/// the preview instead of the current ones.
|
||||
///
|
||||
/// parameters :
|
||||
/// - [jsonAvatarOptions] : String? - The jsonAvatarOptions which are forced
|
||||
/// to set by the user
|
||||
Future<void> saveAvatarSVG({String? jsonAvatarOptions}) async {
|
||||
// Update the selectedOptions if jsonAvatarOptions is not null
|
||||
if (jsonAvatarOptions != null) {
|
||||
selectedOptions = OptionsService.jsonDecodeSelectedOptions(
|
||||
this.propertyCategories, jsonAvatarOptions);
|
||||
}
|
||||
|
||||
// Perform the save operation (implemented by subclasses)
|
||||
await save();
|
||||
|
||||
// Update the preview
|
||||
updatePreview(newAvatarMakerSVG: drawAvatarSVG());
|
||||
}
|
||||
|
||||
/// Perform the save operation. This method should be implemented by subclasses
|
||||
/// to provide the appropriate save strategy.
|
||||
Future<String> save();
|
||||
|
||||
/// Restore controller state with the latest version of
|
||||
/// [displayedAvatarSVG] and [selectedOptions]
|
||||
Future<void> restoreState() async {
|
||||
// Get the SVG and options (implemented by subclasses)
|
||||
final restoredData = await performRestore();
|
||||
|
||||
// Update the preview with the restored SVG or generate a new one
|
||||
updatePreview(newAvatarMakerSVG: restoredData.svg);
|
||||
|
||||
// Update selected options
|
||||
selectedOptions = restoredData.options;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Perform the restore operation. This method should be implemented by subclasses
|
||||
/// to provide the appropriate restore strategy.
|
||||
Future<RestoredData> performRestore();
|
||||
|
||||
/// Generates a [String] SVG from the [selectedOptions] stored.
|
||||
String drawAvatarSVG() {
|
||||
return AvatarService.drawSVG(
|
||||
accessory: selectedOptions[PropertyCategoryIds.Accessory]!.value,
|
||||
backgroundStyle: selectedOptions[PropertyCategoryIds.Background]!.value,
|
||||
eyebrows: selectedOptions[PropertyCategoryIds.EyebrowType]!.value,
|
||||
eyes: selectedOptions[PropertyCategoryIds.EyeType]!.value,
|
||||
facialHair: FacialHairsService.generateFacialHair(
|
||||
color: selectedOptions[PropertyCategoryIds.FacialHairColor]
|
||||
as FacialHairColors,
|
||||
type: selectedOptions[PropertyCategoryIds.FacialHairType]
|
||||
as FacialHairTypes,
|
||||
),
|
||||
hair: HairService.generateHairStyle(
|
||||
color: selectedOptions[PropertyCategoryIds.HairColor] as HairColors,
|
||||
style: selectedOptions[PropertyCategoryIds.HairStyle] as HairStyles,
|
||||
),
|
||||
mouth: selectedOptions[PropertyCategoryIds.MouthType]!.value,
|
||||
nose: selectedOptions[PropertyCategoryIds.Nose]!.value,
|
||||
outfit: OutfitService.generateOutfit(
|
||||
color: selectedOptions[PropertyCategoryIds.OutfitColor] as OutfitColors,
|
||||
type: selectedOptions[PropertyCategoryIds.OutfitType] as OutfitTypes,
|
||||
),
|
||||
skin: selectedOptions[PropertyCategoryIds.SkinColor]!.value,
|
||||
);
|
||||
}
|
||||
|
||||
/// Generates component SVG string for an individual component
|
||||
/// to display as a preview
|
||||
String getComponentSVG(PropertyCategoryIds categoryId, int index) {
|
||||
PropertyItem item = PropertyCategoryService.getPropertyCategoryById(
|
||||
this.propertyCategories, categoryId)
|
||||
.properties![index];
|
||||
if (item.value == "") {
|
||||
return emptySVGIcon;
|
||||
}
|
||||
switch (categoryId) {
|
||||
case PropertyCategoryIds.Accessory:
|
||||
return AccessoryService.drawSVG(accessory: item.value);
|
||||
|
||||
case PropertyCategoryIds.Background:
|
||||
return BackgroundService.drawSVG(background: item.value);
|
||||
|
||||
case PropertyCategoryIds.EyebrowType:
|
||||
return EyebrowService.drawSVG(eyebrow: item.value);
|
||||
|
||||
case PropertyCategoryIds.EyeType:
|
||||
return EyeService.drawSVG(eye: item.value);
|
||||
|
||||
case PropertyCategoryIds.FacialHairColor:
|
||||
return ColorService.drawSVG(hexColorCode: item.value);
|
||||
|
||||
case PropertyCategoryIds.FacialHairType:
|
||||
return FacialHairsService.drawSVG(
|
||||
color: selectedOptions[PropertyCategoryIds.FacialHairColor]
|
||||
as FacialHairColors,
|
||||
type: item as FacialHairTypes,
|
||||
);
|
||||
|
||||
case PropertyCategoryIds.HairColor:
|
||||
return ColorService.drawSVG(hexColorCode: item.value);
|
||||
|
||||
case PropertyCategoryIds.HairStyle:
|
||||
return HairService.drawSVG(
|
||||
color: (selectedOptions[PropertyCategoryIds.HairColor] ??
|
||||
HairColors.values.first) as HairColors,
|
||||
style: item as HairStyles,
|
||||
);
|
||||
|
||||
case PropertyCategoryIds.MouthType:
|
||||
return MouthService.drawSVG(mouth: item.value);
|
||||
|
||||
case PropertyCategoryIds.Nose:
|
||||
return NoseService.drawSVG(nose: item.value);
|
||||
|
||||
case PropertyCategoryIds.OutfitColor:
|
||||
return ColorService.drawSVG(hexColorCode: item.value);
|
||||
|
||||
case PropertyCategoryIds.OutfitType:
|
||||
return OutfitService.drawSVG(
|
||||
color:
|
||||
selectedOptions[PropertyCategoryIds.OutfitColor] as OutfitColors,
|
||||
type: item as OutfitTypes,
|
||||
);
|
||||
|
||||
case PropertyCategoryIds.SkinColor:
|
||||
return SkinService.drawSVG(skinColor: item.value);
|
||||
}
|
||||
}
|
||||
|
||||
/// Randomize the select options of all the displayed property categories.
|
||||
/// All the non displayed categories keep their default value.
|
||||
void randomizedSelectedOptions() {
|
||||
var rng = Random();
|
||||
displayedPropertyCategories.forEach(
|
||||
(propertyCategory) {
|
||||
selectedOptions.update(
|
||||
propertyCategory.id,
|
||||
(value) => propertyCategory.properties!
|
||||
.elementAt(rng.nextInt(propertyCategory.properties!.length)),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
updatePreview();
|
||||
}
|
||||
|
||||
/// Extract the selected options to JSON for an external save.
|
||||
String getJsonOptionsSync() {
|
||||
return OptionsService.jsonEncodeSelectedOptions(selectedOptions);
|
||||
}
|
||||
|
||||
/// Extract the current avatar SVG for an external save.
|
||||
String getAvatarSVGSync() {
|
||||
return drawAvatarSVG();
|
||||
}
|
||||
|
||||
/// Flag to know if the controller used is a persistant one or not.
|
||||
/// Useful for some widgets like the "Reset" or "Save" button to know if it's
|
||||
/// useful to be displayed.
|
||||
bool isPersistentController();
|
||||
}
|
||||
|
||||
/// Class to hold restored data from persistence
|
||||
class RestoredData {
|
||||
final String svg;
|
||||
final Map<PropertyCategoryIds, PropertyItem> options;
|
||||
|
||||
RestoredData({required this.svg, required this.options});
|
||||
}
|
||||
4
avatar_maker/lib/src/core/controllers/controllers.dart
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
// Export all controllers from this directory
|
||||
export 'avatar_maker_controller.dart';
|
||||
export 'persistent_avatar_maker_controller.dart';
|
||||
export 'non_persistent_avatar_maker_controller.dart';
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
import "package:avatar_maker/src/core/controllers/avatar_maker_controller.dart";
|
||||
import "package:avatar_maker/src/core/enums/property_category_ids.dart";
|
||||
import "package:avatar_maker/src/core/models/customized_property_category.dart";
|
||||
import "package:avatar_maker/src/core/models/property_item.dart";
|
||||
import "package:flutter/material.dart";
|
||||
|
||||
/// Non-persistent implementation of the Avatar Maker controller
|
||||
///
|
||||
/// This controller provides all the functionality of the avatar maker
|
||||
/// without persisting any data. It's useful for scenarios where persistence
|
||||
/// is not needed or is handled externally.
|
||||
class NonPersistentAvatarMakerController extends AvatarMakerController {
|
||||
/// In-memory storage for the SVG
|
||||
String _storedSVG = "";
|
||||
|
||||
NonPersistentAvatarMakerController({
|
||||
List<CustomizedPropertyCategory>? customizedPropertyCategories,
|
||||
Map<PropertyCategoryIds, PropertyItem>? selectedOptions,
|
||||
Locale? locale,
|
||||
}) : super(
|
||||
customizedPropertyCategories: customizedPropertyCategories,
|
||||
selectedOptions: selectedOptions,
|
||||
locale: locale,
|
||||
);
|
||||
|
||||
NonPersistentAvatarMakerController.fromSvg(
|
||||
{required String svg,
|
||||
List<CustomizedPropertyCategory>? customizedPropertyCategories,
|
||||
Locale? locale})
|
||||
: super.fromSvg(
|
||||
svg: svg,
|
||||
customizedPropertyCategories: customizedPropertyCategories,
|
||||
locale: locale);
|
||||
|
||||
/// Get the selected options from memory or use defaults
|
||||
@override
|
||||
Future<Map<PropertyCategoryIds, PropertyItem>> getSelectedOptions() async {
|
||||
// If no options are selected yet, use the default options
|
||||
if (selectedOptions.isEmpty) {
|
||||
selectedOptions = Map.from(defaultSelectedOptions);
|
||||
}
|
||||
return selectedOptions;
|
||||
}
|
||||
|
||||
/// Perform the save operation without persistence
|
||||
/// This implementation just updates the in-memory SVG
|
||||
@override
|
||||
Future<String> save() async {
|
||||
// Store the SVG in memory only
|
||||
return Future.value(drawAvatarSVG());
|
||||
}
|
||||
|
||||
/// Perform the restore operation without persistence
|
||||
/// This implementation just returns the current state
|
||||
@override
|
||||
Future<RestoredData> performRestore() async {
|
||||
// Use the stored SVG if available, otherwise generate a new one
|
||||
String svg = _storedSVG.isNotEmpty ? _storedSVG : drawAvatarSVG();
|
||||
|
||||
// Return the current options
|
||||
return RestoredData(svg: svg, options: selectedOptions);
|
||||
}
|
||||
|
||||
/// Flag to know if the controller used is a persistant one or not.
|
||||
/// Useful for some widgets like the "Reset" or "Save" button to know if it's
|
||||
/// useful to be displayed.
|
||||
@override
|
||||
bool isPersistentController() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
import "package:avatar_maker/src/core/controllers/avatar_maker_controller.dart";
|
||||
import "package:avatar_maker/src/core/enums/preferences_label.dart";
|
||||
import "package:avatar_maker/src/core/enums/property_category_ids.dart";
|
||||
import "package:avatar_maker/src/core/models/customized_property_category.dart";
|
||||
import "package:avatar_maker/src/core/models/property_item.dart";
|
||||
import "package:avatar_maker/src/core/services/options_service.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:shared_preferences/shared_preferences.dart";
|
||||
|
||||
/// Brains of the Avatar_Maker package with persistence capabilities
|
||||
///
|
||||
/// Built using the ChangeNotifier architecture to allow the two widgets to easily
|
||||
/// communicate with each other. This controller persists data in SharedPreferences.
|
||||
class PersistentAvatarMakerController extends AvatarMakerController {
|
||||
PersistentAvatarMakerController({
|
||||
List<CustomizedPropertyCategory>? customizedPropertyCategories,
|
||||
Locale? locale,
|
||||
}) : super(
|
||||
customizedPropertyCategories: customizedPropertyCategories,
|
||||
locale: locale,
|
||||
);
|
||||
|
||||
PersistentAvatarMakerController.fromSvg(
|
||||
{required String svg,
|
||||
List<CustomizedPropertyCategory>? customizedPropertyCategories,
|
||||
Locale? locale})
|
||||
: super.fromSvg(
|
||||
svg: svg,
|
||||
customizedPropertyCategories: customizedPropertyCategories,
|
||||
locale: locale);
|
||||
|
||||
/// Get the current stored options from the shared preferences, or set the
|
||||
/// options with the default values if no options where stored.
|
||||
@override
|
||||
Future<Map<PropertyCategoryIds, PropertyItem>> getSelectedOptions() async {
|
||||
return await getStoredOptions();
|
||||
}
|
||||
|
||||
/// Get the current stored options from the shared preferences, or set the
|
||||
/// options with the default values if no options where stored.
|
||||
Future<Map<PropertyCategoryIds, PropertyItem>> getStoredOptions() async {
|
||||
SharedPreferences pref = await SharedPreferences.getInstance();
|
||||
String? _avatarMakerOptions =
|
||||
pref.getString(PreferencesLabel.avatarMakerSelectedOptions.name);
|
||||
|
||||
if (_avatarMakerOptions == null || _avatarMakerOptions.isEmpty) {
|
||||
Map<PropertyCategoryIds, PropertyItem> _avatarMakerOptionsMap = {};
|
||||
_avatarMakerOptionsMap.addAll(defaultSelectedOptions);
|
||||
|
||||
await pref.setString(PreferencesLabel.avatarMakerSelectedOptions.name,
|
||||
OptionsService.jsonEncodeSelectedOptions(_avatarMakerOptionsMap));
|
||||
selectedOptions = _avatarMakerOptionsMap;
|
||||
} else {
|
||||
selectedOptions = OptionsService.jsonDecodeSelectedOptions(
|
||||
this.propertyCategories, _avatarMakerOptions);
|
||||
}
|
||||
notifyListeners();
|
||||
return selectedOptions;
|
||||
}
|
||||
|
||||
/// Perform the save operation by storing data in SharedPreferences
|
||||
@override
|
||||
Future<String> save() async {
|
||||
// Update selectedOptions stored
|
||||
SharedPreferences pref = await SharedPreferences.getInstance();
|
||||
await pref.setString(
|
||||
PreferencesLabel.avatarMakerSelectedOptions.name,
|
||||
OptionsService.jsonEncodeSelectedOptions(selectedOptions),
|
||||
);
|
||||
|
||||
// Get the SVG to display and store
|
||||
final String avatarSVG = drawAvatarSVG();
|
||||
await pref.setString(PreferencesLabel.avatarMakerSVG.name, avatarSVG);
|
||||
return avatarSVG;
|
||||
}
|
||||
|
||||
/// Perform the restore operation by retrieving data from SharedPreferences
|
||||
@override
|
||||
Future<RestoredData> performRestore() async {
|
||||
SharedPreferences pref = await SharedPreferences.getInstance();
|
||||
|
||||
// Get the SVG from preferences or use default
|
||||
String svg = pref.getString(PreferencesLabel.avatarMakerSVG.name) ??
|
||||
OptionsService.jsonEncodeSelectedOptions(defaultSelectedOptions);
|
||||
|
||||
// Get the options
|
||||
Map<PropertyCategoryIds, PropertyItem> options = await getStoredOptions();
|
||||
|
||||
return RestoredData(svg: svg, options: options);
|
||||
}
|
||||
|
||||
/// Erase AvatarMaker user's preferences from local storage
|
||||
static Future<List<bool>> clearAvatarMaker() async {
|
||||
SharedPreferences pref = await SharedPreferences.getInstance();
|
||||
return Future.wait([
|
||||
pref.remove(PreferencesLabel.avatarMakerSelectedOptions.name),
|
||||
pref.remove(PreferencesLabel.avatarMakerSVG.name),
|
||||
]);
|
||||
}
|
||||
|
||||
/// Extract the selected options to JSON for an external save.
|
||||
///
|
||||
/// Method made to simplify actions from library users.
|
||||
static Future<String> getJsonOptions() async {
|
||||
SharedPreferences pref = await SharedPreferences.getInstance();
|
||||
return pref.get(PreferencesLabel.avatarMakerSelectedOptions.name) as String;
|
||||
}
|
||||
|
||||
/// Import the given options in a JSON format to the controller.
|
||||
///
|
||||
/// Method made to simplify actions from library users.
|
||||
///
|
||||
/// [controller] - The AvatarMakerController instance to use
|
||||
static void setJsonOptions(String jsonAvatarOptions,
|
||||
{required PersistentAvatarMakerController controller}) {
|
||||
controller.saveAvatarSVG(jsonAvatarOptions: jsonAvatarOptions);
|
||||
}
|
||||
|
||||
/// Extract the current avatar SVG for an external save.
|
||||
///
|
||||
/// Method made to simplify actions from library users.
|
||||
static Future<String> getAvatarSVG() async {
|
||||
SharedPreferences pref = await SharedPreferences.getInstance();
|
||||
return pref.get(PreferencesLabel.avatarMakerSVG.name) as String;
|
||||
}
|
||||
|
||||
/// Flag to know if the controller used is a persistant one or not.
|
||||
/// Useful for some widgets like the "Reset" or "Save" button to know if it's
|
||||
/// useful to be displayed.
|
||||
@override
|
||||
bool isPersistentController() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
23
avatar_maker/lib/src/core/enums/placeholders.dart
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
/// Placeholders to use for a SVG which will be use for an outfit which can
|
||||
/// change color.
|
||||
const String TO_REPLACE_WITH_OUTFIT_COLOR = "TO_REPLACE_WITH_OUTFIT_COLOR";
|
||||
const String TO_REPLACE_WITH_OUTFIT_COLOR_NAME =
|
||||
"TO_REPLACE_WITH_OUTFIT_COLOR_NAME";
|
||||
|
||||
/// Placeholders to use for a SVG which will be use for facial hairs which can
|
||||
/// change color.
|
||||
const String TO_REPLACE_WITH_FACIAL_HAIRS_COLOR =
|
||||
"TO_REPLACE_WITH_FACIAL_HAIRS_COLOR";
|
||||
const String TO_REPLACE_WITH_FACIAL_HAIRS_COLOR_NAME =
|
||||
"TO_REPLACE_WITH_FACIAL_HAIRS_COLOR_NAME";
|
||||
|
||||
/// Placeholders to use for a SVG which will be use for hairs which can
|
||||
/// change color.
|
||||
const String TO_REPLACE_WITH_HAIRS_COLOR = "TO_REPLACE_WITH_HAIRS_COLOR";
|
||||
const String TO_REPLACE_WITH_HAIRS_COLOR_NAME =
|
||||
"TO_REPLACE_WITH_HAIRS_COLOR_NAME";
|
||||
|
||||
/// Icon to use for a property option which doesn't have anything to display.
|
||||
const String emptySVGIcon = """
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-20 -20 80 80" width="80px" height="80px"><path fill="#dff0fe" stroke="#4788c7" stroke-miterlimit="10" d="M20,1C9.507,1,1,9.507,1,20s8.507,19,19,19s19-8.507,19-19 S30.493,1,20,1z M6,20c0-7.732,6.268-14,14-14c2.963,0,5.706,0.926,7.968,2.496L8.496,27.968C6.926,25.706,6,22.963,6,20z M20,34 c-2.963,0-5.706-0.926-7.968-2.496l19.472-19.472C33.074,14.294,34,17.037,34,20C34,27.732,27.732,34,20,34z"/></svg>
|
||||
""";
|
||||
8
avatar_maker/lib/src/core/enums/preferences_label.dart
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
/// List all the SharedPreferences labels used to store data.
|
||||
enum PreferencesLabel {
|
||||
/// For the selected options
|
||||
avatarMakerSelectedOptions,
|
||||
|
||||
/// For the current SVG to display
|
||||
avatarMakerSVG;
|
||||
}
|
||||
166
avatar_maker/lib/src/core/enums/property_categories.dart
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
/// List of property categories which exist in the library.
|
||||
import "package:avatar_maker/l10n/app_localizations.dart";
|
||||
import "package:avatar_maker/src/core/enums/property_category_ids.dart";
|
||||
import "package:avatar_maker/src/core/enums/property_items/accessories.dart";
|
||||
import "package:avatar_maker/src/core/enums/property_items/background_style.dart";
|
||||
import "package:avatar_maker/src/core/enums/property_items/noses.dart";
|
||||
import "package:avatar_maker/src/core/enums/property_items/outfit_colors.dart";
|
||||
import "package:avatar_maker/src/core/enums/property_items/eyebrows.dart";
|
||||
import "package:avatar_maker/src/core/enums/property_items/eyes.dart";
|
||||
import "package:avatar_maker/src/core/enums/property_items/facial_hair_colors.dart";
|
||||
import "package:avatar_maker/src/core/enums/property_items/facial_hair_types.dart";
|
||||
import "package:avatar_maker/src/core/enums/property_items/hair_colors.dart";
|
||||
import "package:avatar_maker/src/core/enums/property_items/hair_styles.dart";
|
||||
import "package:avatar_maker/src/core/enums/property_items/outfit_types.dart";
|
||||
import "package:avatar_maker/src/core/enums/property_items/mouths.dart";
|
||||
import "package:avatar_maker/src/core/enums/property_items/skin_colors.dart";
|
||||
import "package:avatar_maker/src/core/models/property_category.dart";
|
||||
|
||||
final PropertyCategory Accessory = PropertyCategory(
|
||||
id: PropertyCategoryIds.Accessory,
|
||||
getL10nName: (AppLocalizations l10n) {
|
||||
return l10n.property_category_accessories;
|
||||
},
|
||||
iconFile: "assets/icons/accessories.svg",
|
||||
properties: Accessories.values,
|
||||
toDisplay: true,
|
||||
defaultValue: Accessories.Nothing,
|
||||
);
|
||||
final PropertyCategory Background = PropertyCategory(
|
||||
id: PropertyCategoryIds.Background,
|
||||
getL10nName: (AppLocalizations l10n) {
|
||||
return l10n.property_category_backgrounds;
|
||||
},
|
||||
iconFile: "assets/icons/background.svg",
|
||||
properties: BackgroundStyles.values,
|
||||
toDisplay: false,
|
||||
defaultValue: BackgroundStyles.Transparent,
|
||||
);
|
||||
final PropertyCategory EyebrowType = PropertyCategory(
|
||||
id: PropertyCategoryIds.EyebrowType,
|
||||
getL10nName: (AppLocalizations l10n) {
|
||||
return l10n.property_category_eyebrows;
|
||||
},
|
||||
iconFile: "assets/icons/eyebrows.svg",
|
||||
properties: Eyebrows.values,
|
||||
toDisplay: true,
|
||||
defaultValue: Eyebrows.Default,
|
||||
);
|
||||
final PropertyCategory EyeType = PropertyCategory(
|
||||
id: PropertyCategoryIds.EyeType,
|
||||
getL10nName: (AppLocalizations l10n) {
|
||||
return l10n.property_category_eyes;
|
||||
},
|
||||
iconFile: "assets/icons/eyes.svg",
|
||||
properties: Eyes.values,
|
||||
toDisplay: true,
|
||||
defaultValue: Eyes.Default,
|
||||
);
|
||||
final PropertyCategory FacialHairColor = PropertyCategory(
|
||||
id: PropertyCategoryIds.FacialHairColor,
|
||||
getL10nName: (AppLocalizations l10n) {
|
||||
return l10n.property_category_facial_hair_colors;
|
||||
},
|
||||
iconFile: "assets/icons/facial_hair_color.svg",
|
||||
properties: FacialHairColors.values,
|
||||
toDisplay: true,
|
||||
defaultValue: FacialHairColors.Black,
|
||||
);
|
||||
final PropertyCategory FacialHairType = PropertyCategory(
|
||||
id: PropertyCategoryIds.FacialHairType,
|
||||
getL10nName: (AppLocalizations l10n) {
|
||||
return l10n.property_category_facial_hair_types;
|
||||
},
|
||||
iconFile: "assets/icons/facial_hair.svg",
|
||||
properties: FacialHairTypes.values,
|
||||
toDisplay: true,
|
||||
defaultValue: FacialHairTypes.Nothing,
|
||||
);
|
||||
final PropertyCategory HairStyle = PropertyCategory(
|
||||
id: PropertyCategoryIds.HairStyle,
|
||||
getL10nName: (AppLocalizations l10n) {
|
||||
return l10n.property_category_hairstyles;
|
||||
},
|
||||
iconFile: "assets/icons/hair.svg",
|
||||
properties: HairStyles.values,
|
||||
toDisplay: true,
|
||||
defaultValue: HairStyles.Bald,
|
||||
);
|
||||
final PropertyCategory HairColor = PropertyCategory(
|
||||
id: PropertyCategoryIds.HairColor,
|
||||
getL10nName: (AppLocalizations l10n) {
|
||||
return l10n.property_category_hair_colors;
|
||||
},
|
||||
iconFile: "assets/icons/hair_color.svg",
|
||||
properties: HairColors.values,
|
||||
toDisplay: true,
|
||||
defaultValue: HairColors.Black,
|
||||
);
|
||||
final PropertyCategory MouthType = PropertyCategory(
|
||||
id: PropertyCategoryIds.MouthType,
|
||||
getL10nName: (AppLocalizations l10n) {
|
||||
return l10n.property_category_mouths;
|
||||
},
|
||||
iconFile: "assets/icons/mouth.svg",
|
||||
properties: Mouths.values,
|
||||
toDisplay: true,
|
||||
defaultValue: Mouths.Default,
|
||||
);
|
||||
final PropertyCategory NoseType = PropertyCategory(
|
||||
id: PropertyCategoryIds.Nose,
|
||||
getL10nName: (AppLocalizations l10n) {
|
||||
return l10n.property_category_noses;
|
||||
},
|
||||
iconFile: "assets/icons/noses.svg",
|
||||
properties: Noses.values,
|
||||
toDisplay: false,
|
||||
defaultValue: Noses.Default,
|
||||
);
|
||||
final PropertyCategory OutfitColor = PropertyCategory(
|
||||
id: PropertyCategoryIds.OutfitColor,
|
||||
getL10nName: (AppLocalizations l10n) {
|
||||
return l10n.property_category_outfit_colors;
|
||||
},
|
||||
iconFile: "assets/icons/outfit_color.svg",
|
||||
properties: OutfitColors.values,
|
||||
toDisplay: true,
|
||||
defaultValue: OutfitColors.PastelBlue,
|
||||
);
|
||||
final PropertyCategory OutfitType = PropertyCategory(
|
||||
id: PropertyCategoryIds.OutfitType,
|
||||
getL10nName: (AppLocalizations l10n) {
|
||||
return l10n.property_category_outfit_types;
|
||||
},
|
||||
iconFile: "assets/icons/outfit.svg",
|
||||
properties: OutfitTypes.values,
|
||||
toDisplay: true,
|
||||
defaultValue: OutfitTypes.Hoodie,
|
||||
);
|
||||
final PropertyCategory SkinColor = PropertyCategory(
|
||||
id: PropertyCategoryIds.SkinColor,
|
||||
getL10nName: (AppLocalizations l10n) {
|
||||
return l10n.property_category_skins;
|
||||
},
|
||||
iconFile: "assets/icons/skin.svg",
|
||||
properties: SkinColors.values,
|
||||
toDisplay: true,
|
||||
defaultValue: SkinColors.Brown,
|
||||
);
|
||||
|
||||
// List of all the default property categories. The order here represents the
|
||||
// tab order to display in the Customizer widget.
|
||||
final List<PropertyCategory> defaultPropertyCategories = [
|
||||
HairStyle,
|
||||
HairColor,
|
||||
FacialHairType,
|
||||
FacialHairColor,
|
||||
EyeType,
|
||||
EyebrowType,
|
||||
NoseType,
|
||||
MouthType,
|
||||
SkinColor,
|
||||
OutfitType,
|
||||
OutfitColor,
|
||||
Accessory,
|
||||
Background,
|
||||
];
|
||||
16
avatar_maker/lib/src/core/enums/property_category_ids.dart
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
/// List of all the Property Category ids.
|
||||
enum PropertyCategoryIds {
|
||||
Accessory,
|
||||
Background,
|
||||
EyebrowType,
|
||||
EyeType,
|
||||
FacialHairColor,
|
||||
FacialHairType,
|
||||
HairColor,
|
||||
HairStyle,
|
||||
MouthType,
|
||||
Nose,
|
||||
OutfitColor,
|
||||
OutfitType,
|
||||
SkinColor;
|
||||
}
|
||||
130
avatar_maker/lib/src/core/enums/property_items/accessories.dart
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import "package:avatar_maker/src/core/models/property_item.dart";
|
||||
|
||||
/// List of all the accessories displayed by default.
|
||||
enum Accessories implements PropertyItem {
|
||||
Nothing(""),
|
||||
Kurt("""
|
||||
<g id="Accessories/Kurts" filter="url(#react-filter-96666)" transform="translate(-10.000000, 6.000000)">
|
||||
<path d="M66,11.1111111 C54.9625586,11.1111111 53.3705645,2.0266011 30.6705882,0.740740741 C7.98552275,-0.283199952 0.815225204,6.4494855 0.776470588,11.1111111 C0.813236892,15.4042795 -0.352293566,26.5612661 14.3647059,39.6296296 C29.1367705,55.1420807 44.2704162,49.8818301 49.6941176,44.8148148 C55.1352081,42.4731118 61.3403442,21.4596351 66,21.4814815 C70.6596558,21.5033279 76.8647919,42.4731118 82.3058824,44.8148148 C87.7295838,49.8818301 102.86323,55.1420807 117.635294,39.6296296 C132.352294,26.5612661 131.186763,15.4042795 131.223529,11.1111111 C131.184775,6.4494855 124.014477,-0.283199952 101.329412,0.740740741 C78.6294355,2.0266011 77.0374414,11.1111111 66,11.1111111 Z" id="It!" fill="#F4F4F4" fill-rule="nonzero"></path>
|
||||
<path d="M55.1294118,21.4814815 C55.5103632,13.8233491 42.2156493,5.64243259 27.9529412,5.92592593 C13.6973442,6.22450879 11.8417942,15.3786982 11.6470588,18.8888889 C11.2982286,27.0220633 20.014463,45.3037598 36.1058824,44.8148148 C52.1972736,44.305848 54.9092435,26.5344305 55.1294118,21.4814815 Z" id="Did" fill="#2F383B" fill-rule="nonzero"></path>
|
||||
<path d="M120.352941,21.4814815 C120.733893,13.8233491 107.439179,5.64243259 93.1764706,5.92592593 C78.9208736,6.22450879 77.0653236,15.3786982 76.8705882,18.8888889 C76.521758,27.0220633 85.2379924,45.3037598 101.329412,44.8148148 C117.420803,44.305848 120.132773,26.5344305 120.352941,21.4814815 Z" id="Courtney" fill="#2F383B" fill-rule="nonzero" transform="translate(98.611765, 25.370370) scale(-1, 1) translate(-98.611765, -25.370370) "></path>
|
||||
</g>
|
||||
"""),
|
||||
Glasses("""
|
||||
<g id="Accessories/Glasses" filter="url(#react-filter-95687)" transform="translate(-6, 10.000000)" fill="#D6EAF2">
|
||||
<path d="M46.2491397,7.27516667 C48.6207695,7.2975 49.7419096,7.69183333 50.1459601,10.1651667 C50.5553446,12.6705 50.1572949,15.4871667 49.6852359,17.9548333 C48.9648125,21.7228333 47.7666627,25.4145 44.9776475,28.1685 C43.5084639,29.6188333 41.7165732,30.7748333 39.8106684,31.5641667 C38.7985419,31.9835 37.7297416,32.2861667 36.6612747,32.5158333 C36.3489024,32.5828333 33.6822357,32.9501667 35.3177735,32.7635 C31.5009631,33.1991667 27.3601122,33.1818333 24.1723805,30.7525 C20.6396056,28.0601667 18.2203032,23.7998333 17.1565036,19.5561667 C16.5340925,17.0731667 15.2262624,11.1345 17.6158944,9.14916667 C20.3532365,6.8745 46.2491397,7.27516667 46.2491397,7.27516667 L46.2491397,7.27516667 Z M22.2178029,0.4905 C16.7774562,0.677833333 13.1466691,1.63383333 10.4633337,7.06916667 C5.54571911,17.0301667 13.9627711,31.9688333 23.352278,36.0395 C34.3293166,40.7991667 46.5921826,35.5318333 52.3955746,26.0058333 C55.4689587,20.9621667 57.0224862,13.3231667 56.9224737,7.50383333 C56.7951245,0.0765 51.6071427,-0.1295 45.5090472,0.0338333333 L22.2178029,0.4905 Z" id="Frame-Stuff"></path>
|
||||
<path d="M79.6805515,7.27256667 C77.3089217,7.29523333 76.1877816,7.68923333 75.7837311,10.1625667 C75.3743466,12.6679 75.7723963,15.4845667 76.244122,17.9522333 C76.9648787,21.7202333 78.1630285,25.4119 80.9520437,28.1659 C82.4212273,29.6162333 84.213118,30.7722333 86.1190228,31.5619 C87.1311493,31.9809 88.1999496,32.2835667 89.2684165,32.5132333 C89.5807888,32.5802333 92.2471221,32.9479 90.6119177,32.7609 C94.4287281,33.1965667 98.569579,33.1792333 101.757311,30.7499 C105.290086,28.0575667 107.709388,23.7975667 108.773188,19.5539 C109.395599,17.0705667 110.703095,11.1322333 108.313797,9.14656667 C105.576455,6.8719 79.6805515,7.27256667 79.6805515,7.27256667 L79.6805515,7.27256667 Z M103.711555,0.4879 C109.152235,0.675233333 112.783022,1.63156667 115.466357,7.06656667 C120.383639,17.0275667 111.96692,31.9662333 102.577413,36.0372333 C91.6003746,40.7965667 79.3375086,35.5292333 73.5337832,26.0035667 C70.4607325,20.9595667 68.907205,13.3205667 69.0068841,7.50123333 C69.1345667,0.0739 74.3225485,-0.1321 80.420644,0.0315666667 L103.711555,0.4879 Z" id="Frame-Stuff"></path>
|
||||
<path d="M13.1969483,4.9267 C9.78501392,5.11836667 5.88606327,5.16436667 2.69005822,6.63936667 C-0.69461078,8.20136667 -1.2176675,11.7387 3.04920921,12.2260333 C4.97094906,12.4457 6.89488267,12.0827 8.78716336,11.7450333 C10.336903,11.4683667 12.4419791,11.5580333 13.9064752,10.9657 C16.6355213,9.86236667 16.4603333,4.74003333 13.1969483,4.9267" id="Frame-Stuff"></path>
|
||||
<path d="M112.73467,4.9267 C116.146606,5.11836667 120.045559,5.16436667 123.241565,6.63936667 C126.626236,8.20136667 127.149293,11.7387 122.882414,12.2260333 C120.960673,12.4457 119.036739,12.0827 117.144457,11.7450333 C115.594717,11.4683667 113.489639,11.5580333 112.025143,10.9657 C109.295782,9.86236667 109.471283,4.74003333 112.73467,4.9267" id="Frame-Stuff"></path>
|
||||
<path d="M73.1094302,7.01263333 C71.1631869,4.71263333 66.0912197,3.38463333 62.8914864,3.38463333 C59.6914198,3.38463333 54.7681378,4.71263333 52.8222279,7.01263333 C51.8407719,8.1723 51.8074344,9.72396667 53.5083137,10.4509667 C55.6262451,11.3566333 57.5174814,9.7143 59.2126933,8.8553 C61.3809643,7.75663333 64.7120473,7.8773 66.7189648,8.8553 C68.4271783,9.68796667 70.3050797,11.3566333 72.4233444,10.4509667 C74.1242237,9.72396667 74.0908862,8.1723 73.1094302,7.01263333" id="Frame-Stuff"></path>
|
||||
</g>
|
||||
|
||||
"""),
|
||||
PrescriptionGlasses("""
|
||||
<g id="Accessories/PrescriptionGlasses" filter="url(#react-filter-97358)" transform="translate(-8.000000, 10.000000)" fill="#252C2F">
|
||||
<path d="M34,41 L31.2421498,41 C17.3147125,41 9,33.3359286 9,20.5 C9,10.127 10.8170058,0 32.5299306,0 L35.4700694,0 C57.1829942,0 59,10.127 59,20.5 C59,32.5686429 48.7212748,41 34,41 Z M32.3853606,6 C13,6 13,12.8410159 13,21.5015498 C13,28.5719428 16.116254,37 30.9709365,37 L34,37 C46.3649085,37 55,30.6270373 55,21.5015498 C55,12.8410159 55,6 35.6146394,6 L32.3853606,6 Z" id="Left" fill-rule="nonzero"></path>
|
||||
<path d="M96,41 L93.2421498,41 C79.3147125,41 71,33.3359286 71,20.5 C71,10.127 72.8170058,0 94.5299306,0 L97.4700694,0 C119.182994,0 121,10.127 121,20.5 C121,32.5686429 110.721275,41 96,41 Z M94.3853606,6 C75,6 75,12.8410159 75,21.5015498 C75,28.5719428 78.1194833,37 92.9709365,37 L96,37 C108.364909,37 117,30.6270373 117,21.5015498 C117,12.8410159 117,6 97.6146394,6 L94.3853606,6 Z" id="Right" fill-rule="nonzero"></path>
|
||||
<path d="M2.95454545,5.77156439 C3.64590909,5.09629136 11.2095455,0 32.5,0 C50.3513636,0 54.1302273,1.85267217 59.8502273,4.6518809 L60.2689233,4.85850899 C60.6666014,4.99901896 62.7002447,5.68982981 65.0790606,5.76579519 C67.2462948,5.67278567 69.1000195,5.08540191 69.641698,4.89719767 C76.1703915,1.7220864 82.5610971,0 97.5,0 C118.790455,0 126.354091,5.09629136 127.045455,5.77156439 C128.679318,5.77156439 130,7.06150904 130,8.65734659 L130,11.5431288 C130,13.1389663 128.679318,14.428911 127.045455,14.428911 C127.045455,14.428911 120.143997,14.428911 120.143997,17.3146932 C120.143997,20.2004754 118.181818,13.1389663 118.181818,11.5431288 L118.181818,8.73240251 C114.578575,7.35340151 108.128411,4.78617535 97.5,4.78617535 C85.6584651,4.78617535 79.7610984,6.88602813 74.7022935,8.97112368 L74.7588636,9.10752861 L74.7563667,11.0937608 L72.5391666,16.4436339 L69.8004908,15.3608351 C69.5558969,15.2641292 69.0281396,15.090392 68.2963505,14.9099044 C66.256272,14.4067419 64.1589087,14.253569 62.3040836,14.6343084 C61.6235903,14.7739931 60.9922286,14.9836085 60.4128127,15.266732 L57.7704824,16.5578701 L55.1266751,11.3962031 L55.2440909,9.10175705 L55.3248203,8.90683855 C50.9620526,6.87386374 46.9392639,4.78617535 32.5,4.78617535 C21.8721459,4.78617535 15.422131,7.3524397 11.8181818,8.7314671 L11.8181818,11.5431288 C11.8181818,13.1389663 8.86363636,20.2004754 8.86363636,17.3146932 C8.86363636,14.428911 2.95454545,14.428911 2.95454545,14.428911 C1.32363636,14.428911 0,13.1389663 0,11.5431288 L0,8.65734659 C0,7.06150904 1.32363636,5.77156439 2.95454545,5.77156439 Z" id="Stuff" fill-rule="nonzero"></path>
|
||||
</g>
|
||||
"""),
|
||||
Round("""
|
||||
<g id="Accessories/Round" fill="none" transform="translate(-15.000000, 4.000000)">
|
||||
<defs>
|
||||
<path d="M80.4646192,16.3941179 C84.3801509,8.45869677 92.552602,3 102,3 C110.919691,3 118.702887,7.86591082 122.839921,15.0880638 C123.050197,15.0306504 123.271519,15 123.5,15 L131.5,15 C132.880712,15 134,16.1192881 134,17.5 C134,18.8807119 132.880712,20 131.5,20 L124.963126,20 C125.637355,22.2145921 126,24.5649459 126,27 C126,40.254834 115.254834,51 102,51 C88.745166,51 78,40.254834 78,27 C78,25.5781066 78.1236513,24.1850936 78.3607874,22.8311273 C78.2762458,18.4553035 74.9135957,15 70.8624171,15 C67.1256697,15 63.9747186,17.9397535 63.4417635,21.8300629 C63.8073299,23.4951922 64,25.2250958 64,27 C64,40.254834 53.254834,51 40,51 C26.745166,51 16,40.254834 16,27 C16,24.5649459 16.3626451,22.2145921 17.0368738,20 L10.5,20 C9.11928813,20 8,18.8807119 8,17.5 C8,16.1192881 9.11928813,15 10.5,15 L10.5,15 L18.5,15 C18.728481,15 18.9498033,15.0306504 19.1600793,15.0880638 C23.2971127,7.86591082 31.0803092,3 40,3 C49.3521568,3 57.4549431,8.34919095 61.415666,16.15488 C63.4929212,13.0392725 66.9494432,11 70.8624171,11 C74.8746823,11 78.4070368,13.1440781 80.4646192,16.3941179 Z M40,47 C51.045695,47 60,38.045695 60,27 C60,15.954305 51.045695,7 40,7 C28.954305,7 20,15.954305 20,27 C20,38.045695 28.954305,47 40,47 Z M102,47 C113.045695,47 122,38.045695 122,27 C122,15.954305 113.045695,7 102,7 C90.954305,7 82,15.954305 82,27 C82,38.045695 90.954305,47 102,47 Z" id="react-path-8588"></path>
|
||||
<filter x="-0.8%" y="-2.1%" width="101.6%" height="108.3%" filterUnits="objectBoundingBox" id="react-filter-8589">
|
||||
<feOffset dx="0" dy="2" in="SourceAlpha" result="shadowOffsetOuter1"></feOffset>
|
||||
<feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0" type="matrix" in="shadowOffsetOuter1"></feColorMatrix>
|
||||
</filter>
|
||||
</defs>
|
||||
<g id="Lennon-Glasses">
|
||||
<use fill="black" fill-opacity="1" filter="url(#react-filter-8589)" xlink:href="#react-path-8588"></use>
|
||||
<use fill="#252C2F" fill-rule="evenodd" xlink:href="#react-path-8588"></use>
|
||||
</g>
|
||||
</g>
|
||||
"""),
|
||||
Sunglasses("""
|
||||
<g id="Accessories/Sunglasses" fill="none" transform="translate(-15, 4.000000)" stroke-width="1">
|
||||
<defs>
|
||||
<filter x="-0.8%" y="-2.6%" width="101.6%" height="110.5%" filterUnits="objectBoundingBox" id="react-filter-9084">
|
||||
<feOffset dx="0" dy="2" in="SourceAlpha" result="shadowOffsetOuter1"></feOffset>
|
||||
<feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0" type="matrix" in="shadowOffsetOuter1" result="shadowMatrixOuter1"></feColorMatrix>
|
||||
<feMerge>
|
||||
<feMergeNode in="shadowMatrixOuter1"></feMergeNode>
|
||||
<feMergeNode in="SourceGraphic"></feMergeNode>
|
||||
</feMerge>
|
||||
</filter>
|
||||
<path d="M47.0104611,6.27728008 C49.5212682,6.30134922 50.7082016,6.72633117 51.1359635,9.39189846 C51.5693724,12.0919529 51.1479634,15.1275382 50.648202,17.7869984 C49.8855011,21.8478573 48.6170388,25.8264499 45.6643523,28.794498 C44.1089507,30.3575551 42.2119044,31.6034024 40.1941529,32.4540848 C39.1226305,32.9060098 37.9911085,33.2322006 36.8599395,33.4797175 C36.5292357,33.5519249 33.7060778,33.9478084 35.4375958,33.7466335 C31.3967988,34.2161613 27.0129452,34.1974808 23.6381438,31.5793333 C19.8980507,28.6777448 17.3367734,24.0862872 16.2105455,19.5127916 C15.5516086,16.8368063 14.1670294,10.4365709 16.6968952,8.29693227 C19.5948762,5.84547255 47.0104611,6.27728008 47.0104611,6.27728008 L47.0104611,6.27728008 Z" id="react-path-9082"></path>
|
||||
<path d="M78.9192315,6.27468008 C76.4084239,6.29910846 75.2214902,6.72373117 74.7937283,9.38929846 C74.3603192,12.0893529 74.7817283,15.1249382 75.2811369,17.7843984 C76.0441909,21.8452573 77.3126534,25.8238499 80.2653406,28.791898 C81.8207425,30.3549551 83.7177893,31.6008024 85.7355412,32.4518441 C86.8070638,32.9034098 87.938586,33.2296006 89.0697553,33.4771175 C89.4004591,33.5493249 92.2232647,33.9455676 90.4920992,33.7440335 C94.5328971,34.2135613 98.9167517,34.1948808 102.291554,31.5767333 C106.031648,28.6751448 108.592926,24.0840464 109.719154,19.5105508 C110.378091,16.8342063 111.762317,10.4343302 109.232804,8.29433227 C106.334822,5.84287255 78.9192315,6.27468008 78.9192315,6.27468008 L78.9192315,6.27468008 Z" id="react-path-9083"></path>
|
||||
<linearGradient x1="50%" y1="0%" x2="50%" y2="70.5058195%" id="react-linear-gradient-9085">
|
||||
<stop stop-color="#FFFFFF" stop-opacity="0.5" offset="0%"></stop>
|
||||
<stop stop-color="#000000" stop-opacity="0.5" offset="100%"></stop>
|
||||
</linearGradient>
|
||||
<linearGradient x1="50%" y1="0%" x2="50%" y2="100%" id="react-linear-gradient-9086">
|
||||
<stop stop-color="#FFFFFF" stop-opacity="0.5" offset="0%"></stop>
|
||||
<stop stop-color="#000000" stop-opacity="0.5" offset="70.5058195%"></stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g id="Sunglasses" filter="url(#react-filter-9084)" transform="translate(8.000000, 8.000000)">
|
||||
<g id="shades">
|
||||
<use fill-opacity="0.700000048" fill="#000000" xlink:href="#react-path-9082"></use>
|
||||
<use fill="url(#react-linear-gradient-9085)" style="mix-blend-mode:screen" xlink:href="#react-path-9082"></use>
|
||||
</g>
|
||||
<g id="shades">
|
||||
<use fill-opacity="0.700000048" fill="#000000" xlink:href="#react-path-9083"></use>
|
||||
<use fill="url(#react-linear-gradient-9086)" style="mix-blend-mode:screen" xlink:href="#react-path-9083"></use>
|
||||
</g>
|
||||
<g id="Glasses" fill="#252C2F">
|
||||
<path d="M46.2491397,7.27516667 C48.6207695,7.2975 49.7419096,7.69183333 50.1459601,10.1651667 C50.5553446,12.6705 50.1572949,15.4871667 49.6852359,17.9548333 C48.9648125,21.7228333 47.7666627,25.4145 44.9776475,28.1685 C43.5084639,29.6188333 41.7165732,30.7748333 39.8106684,31.5641667 C38.7985419,31.9835 37.7297416,32.2861667 36.6612747,32.5158333 C36.3489024,32.5828333 33.6822357,32.9501667 35.3177735,32.7635 C31.5009631,33.1991667 27.3601122,33.1818333 24.1723805,30.7525 C20.6396056,28.0601667 18.2203032,23.7998333 17.1565036,19.5561667 C16.5340925,17.0731667 15.2262624,11.1345 17.6158944,9.14916667 C20.3532365,6.8745 46.2491397,7.27516667 46.2491397,7.27516667 L46.2491397,7.27516667 Z M22.2178029,0.4905 C16.7774562,0.677833333 13.1466691,1.63383333 10.4633337,7.06916667 C5.54571911,17.0301667 13.9627711,31.9688333 23.352278,36.0395 C34.3293166,40.7991667 46.5921826,35.5318333 52.3955746,26.0058333 C55.4689587,20.9621667 57.0224862,13.3231667 56.9224737,7.50383333 C56.7951245,0.0765 51.6071427,-0.1295 45.5090472,0.0338333333 L22.2178029,0.4905 Z" id="Frame"></path>
|
||||
<path d="M79.6805515,7.27256667 C77.3089217,7.29523333 76.1877816,7.68923333 75.7837311,10.1625667 C75.3743466,12.6679 75.7723963,15.4845667 76.244122,17.9522333 C76.9648787,21.7202333 78.1630285,25.4119 80.9520437,28.1659 C82.4212273,29.6162333 84.213118,30.7722333 86.1190228,31.5619 C87.1311493,31.9809 88.1999496,32.2835667 89.2684165,32.5132333 C89.5807888,32.5802333 92.2471221,32.9479 90.6119177,32.7609 C94.4287281,33.1965667 98.569579,33.1792333 101.757311,30.7499 C105.290086,28.0575667 107.709388,23.7975667 108.773188,19.5539 C109.395599,17.0705667 110.703095,11.1322333 108.313797,9.14656667 C105.576455,6.8719 79.6805515,7.27256667 79.6805515,7.27256667 L79.6805515,7.27256667 Z M103.711555,0.4879 C109.152235,0.675233333 112.783022,1.63156667 115.466357,7.06656667 C120.383639,17.0275667 111.96692,31.9662333 102.577413,36.0372333 C91.6003746,40.7965667 79.3375086,35.5292333 73.5337832,26.0035667 C70.4607325,20.9595667 68.907205,13.3205667 69.0068841,7.50123333 C69.1345667,0.0739 74.3225485,-0.1321 80.420644,0.0315666667 L103.711555,0.4879 Z" id="Frame"></path>
|
||||
<path d="M13.1969483,4.9267 C9.78501392,5.11836667 5.88606327,5.16436667 2.69005822,6.63936667 C-0.69461078,8.20136667 -1.2176675,11.7387 3.04920921,12.2260333 C4.97094906,12.4457 6.89488267,12.0827 8.78716336,11.7450333 C10.336903,11.4683667 12.4419791,11.5580333 13.9064752,10.9657 C16.6355213,9.86236667 16.4603333,4.74003333 13.1969483,4.9267" id="Frame"></path>
|
||||
<path d="M112.73467,4.9267 C116.146606,5.11836667 120.045559,5.16436667 123.241565,6.63936667 C126.626236,8.20136667 127.149293,11.7387 122.882414,12.2260333 C120.960673,12.4457 119.036739,12.0827 117.144457,11.7450333 C115.594717,11.4683667 113.489639,11.5580333 112.025143,10.9657 C109.295782,9.86236667 109.471283,4.74003333 112.73467,4.9267" id="Frame"></path>
|
||||
<path d="M73.1094302,7.01263333 C71.1631869,4.71263333 66.0912197,3.38463333 62.8914864,3.38463333 C59.6914198,3.38463333 54.7681378,4.71263333 52.8222279,7.01263333 C51.8407719,8.1723 51.8074344,9.72396667 53.5083137,10.4509667 C55.6262451,11.3566333 57.5174814,9.7143 59.2126933,8.8553 C61.3809643,7.75663333 64.7120473,7.8773 66.7189648,8.8553 C68.4271783,9.68796667 70.3050797,11.3566333 72.4233444,10.4509667 C74.1242237,9.72396667 74.0908862,8.1723 73.1094302,7.01263333" id="Frame"></path>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
"""),
|
||||
Wayfarers("""
|
||||
<g id="Accessories/Wayfarers" fill="none" transform="translate(-15.000000, 4.000000)" stroke-width="1">
|
||||
<defs>
|
||||
<filter x="-0.8%" y="-2.4%" width="101.6%" height="109.8%" filterUnits="objectBoundingBox" id="react-filter-9890">
|
||||
<feOffset dx="0" dy="2" in="SourceAlpha" result="shadowOffsetOuter1"></feOffset>
|
||||
<feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.2 0" type="matrix" in="shadowOffsetOuter1" result="shadowMatrixOuter1"></feColorMatrix>
|
||||
<feMerge>
|
||||
<feMergeNode in="shadowMatrixOuter1"></feMergeNode>
|
||||
<feMergeNode in="SourceGraphic"></feMergeNode>
|
||||
</feMerge>
|
||||
</filter>
|
||||
<linearGradient x1="50%" y1="0%" x2="50%" y2="100%" id="react-linear-gradient-9891">
|
||||
<stop stop-color="#FFFFFF" stop-opacity="0.5" offset="0%"></stop>
|
||||
<stop stop-color="#000000" stop-opacity="0.5" offset="70.5058195%"></stop>
|
||||
</linearGradient>
|
||||
<path d="M44.9178864,17.5714286 C44.9178864,27.2737857 36.66775,35.1428571 22.9204545,35.1428571 L20.1704091,35.1428571 C6.42311364,35.1428571 0.923022727,27.2708571 0.923022727,17.5714286 L0.923022727,17.5714286 C0.923022727,7.86614286 2.20715909,0 21.4545455,0 L24.3863636,0 C43.63375,0 44.9178864,7.86614286 44.9178864,17.5714286 L44.9178864,17.5714286 Z" id="react-path-9888"></path>
|
||||
<path d="M106.486068,17.5714286 C106.486068,27.2737857 98.2388636,35.1428571 84.4886364,35.1428571 L81.7385909,35.1428571 C67.9912955,35.1428571 62.4912045,27.2708571 62.4912045,17.5714286 L62.4912045,17.5714286 C62.4912045,7.86614286 63.7753409,0 83.0227273,0 L85.9545455,0 C105.199,0 106.486068,7.86614286 106.486068,17.5714286 L106.486068,17.5714286 Z" id="react-path-9889"></path>
|
||||
</defs>
|
||||
<g id="Wayfarers" filter="url(#react-filter-9890)" transform="translate(7.000000, 7.000000)">
|
||||
<g id="Shades" transform="translate(10.795455, 2.928571)" fill-rule="nonzero">
|
||||
<g id="Shade">
|
||||
<use fill-opacity="0.700000048" fill="#000000" fill-rule="evenodd" xlink:href="#react-path-9888"></use>
|
||||
<use fill="url(#react-linear-gradient-9891)" fill-rule="evenodd" style="mix-blend-mode:screen" xlink:href="#react-path-9888"></use>
|
||||
</g>
|
||||
<g id="Shade">
|
||||
<use fill-opacity="0.700000048" fill="#000000" fill-rule="evenodd" xlink:href="#react-path-9889"></use>
|
||||
<use fill="url(#react-linear-gradient-9891)" fill-rule="evenodd" style="mix-blend-mode:screen" xlink:href="#react-path-9889"></use>
|
||||
</g>
|
||||
</g>
|
||||
<path d="M33.7159091,41 L30.9658636,41 C17.0778409,41 8.78665909,33.3359286 8.78665909,20.5 C8.78665909,10.127 10.5985227,0 32.25,0 L35.1818182,0 C56.8332955,0 58.6451591,10.127 58.6451591,20.5 C58.6451591,32.5686429 48.3955227,41 33.7159091,41 Z M32.25,5.85421429 C14.6502955,5.85421429 14.6502955,12.3175714 14.6502955,20.5 C14.6502955,27.1800714 17.4795,35.1428571 30.9658636,35.1428571 L33.7159091,35.1428571 C44.9418409,35.1428571 52.7815227,29.1217143 52.7815227,20.5 C52.7815227,12.3175714 52.7815227,5.85421429 35.1818182,5.85421429 L32.25,5.85421429 Z" id="Left" fill="#252C2F" fill-rule="nonzero"></path>
|
||||
<path d="M95.2840909,41 L92.5340455,41 C78.6460227,41 70.3548409,33.3359286 70.3548409,20.5 C70.3548409,10.127 72.1667045,0 93.8181818,0 L96.75,0 C118.401477,0 120.213341,10.127 120.213341,20.5 C120.213341,32.5686429 109.963705,41 95.2840909,41 Z M93.8181818,5.85421429 C76.2184773,5.85421429 76.2184773,12.3175714 76.2184773,20.5 C76.2184773,27.1800714 79.0506136,35.1428571 92.5340455,35.1428571 L95.2840909,35.1428571 C106.510023,35.1428571 114.349705,29.1217143 114.349705,20.5 C114.349705,12.3175714 114.349705,5.85421429 96.75,5.85421429 L93.8181818,5.85421429 Z" id="Right" fill="#252C2F" fill-rule="nonzero"></path>
|
||||
<path d="M2.93181818,5.85714286 C3.61786364,5.17185714 11.1233182,0 32.25,0 C49.9640455,0 53.7138409,1.88014286 59.3898409,4.72085714 L59.8053162,4.93054903 C60.1999353,5.07314243 62.2179351,5.77419634 64.5784525,5.85128811 C66.7290156,5.75689949 68.5684809,5.16080623 69.1059926,4.96981137 C75.5844654,1.74762081 81.9260118,0 96.75,0 C117.876682,0 125.382136,5.17185714 126.068182,5.85714286 C127.689477,5.85714286 129,7.16621429 129,8.78571429 L129,11.7142857 C129,13.3337857 127.689477,14.6428571 126.068182,14.6428571 C126.068182,14.6428571 120.204545,14.6428571 120.204545,17.5714286 C120.204545,20.5 117.272727,13.3337857 117.272727,11.7142857 L117.272727,8.8618831 C113.697201,7.46243482 107.296654,5.85714286 96.75,5.85714286 C84.9995538,5.85714286 79.1475515,6.98813142 74.1276604,9.10414393 L74.1837955,9.24257143 L71.6878772,10.2500422 L74.1813177,11.2582547 L71.981173,16.6874536 L69.263564,15.5885995 C69.0208516,15.4904597 68.4971539,15.3141463 67.770994,15.1309826 C65.7466083,14.6203594 63.6653786,14.4649153 61.8248214,14.8513001 C61.1495627,14.993056 60.5230576,15.2057795 59.9480988,15.4931011 L57.3260941,16.8033836 L54.7026238,11.5651815 L57.3246285,10.2548989 L57.3310023,10.251716 L54.8191364,9.23671429 L54.8992448,9.03890561 C50.5700368,6.97578666 46.5781927,5.85714286 32.25,5.85714286 C21.7038986,5.85714286 15.3034993,7.46145875 11.7272727,8.86093383 L11.7272727,11.7142857 C11.7272727,13.3337857 8.79545455,20.5 8.79545455,17.5714286 C8.79545455,14.6428571 2.93181818,14.6428571 2.93181818,14.6428571 C1.31345455,14.6428571 0,13.3337857 0,11.7142857 L0,8.78571429 C0,7.16621429 1.31345455,5.85714286 2.93181818,5.85714286 Z" id="Stuff" fill="#252C2F" fill-rule="nonzero"></path>
|
||||
</g>
|
||||
</g>
|
||||
""");
|
||||
|
||||
final String svg;
|
||||
|
||||
const Accessories(this.svg);
|
||||
|
||||
String get label => name;
|
||||
String get id => "Accessories/$name";
|
||||
String get value => svg;
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import "package:avatar_maker/src/core/models/property_item.dart";
|
||||
|
||||
/// List of all the background styles displayed by default.
|
||||
enum BackgroundStyles implements PropertyItem {
|
||||
Transparent(""),
|
||||
Circle("""
|
||||
<g id="Circle" stroke-width="1" fill-rule="evenodd" transform="translate(12.000000, 40.000000)">
|
||||
<mask id="mask-2" fill="white"><use xlink:href="#path-1">
|
||||
</use>
|
||||
</mask>
|
||||
<use id="Circle-Background" fill="#E6E6E6" xlink:href="#path-1"></use>
|
||||
<g id="Color/Palette/Blue-01" mask="url(#mask-2)" fill="#65C9FF">
|
||||
<rect id="🖍Color" x="0" y="0" width="240" height="240"></rect>
|
||||
</g>
|
||||
</g>
|
||||
<mask id="mask-4" fill="white"><use xlink:href="#path-3"></use></mask>""");
|
||||
|
||||
final String svg;
|
||||
|
||||
const BackgroundStyles(this.svg);
|
||||
|
||||
String get label => this.name;
|
||||
String get id => "Background/$name";
|
||||
String get value => this.svg;
|
||||
}
|
||||
202
avatar_maker/lib/src/core/enums/property_items/eyebrows.dart
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
import "package:avatar_maker/src/core/models/property_item.dart";
|
||||
|
||||
/// List of all the eyebrows displayed by default.
|
||||
enum Eyebrows implements PropertyItem {
|
||||
Angry("""
|
||||
<g
|
||||
id="Eyebrows/Angry"
|
||||
fillOpacity="0.599999964"
|
||||
fillRule="nonzero">
|
||||
<path
|
||||
d="M15.6114904,15.1845247 C19.8515017,9.41618792 22.4892046,9.70087612 28.9238518,14.5564693 C29.1057771,14.6937504 29.2212592,14.7812575 29.5936891,15.063789 C34.4216439,18.7263562 36.7081807,20 40,20 C41.1045695,20 42,19.1045695 42,18 C42,16.8954305 41.1045695,16 40,16 C37.9337712,16 36.0986396,14.9777974 32.011227,11.8770179 C31.6358269,11.5922331 31.5189458,11.5036659 31.3332441,11.3635351 C27.5737397,8.52660822 25.3739873,7.28738405 22.6379899,6.99208688 C18.9538127,6.59445233 15.5799484,8.47367246 12.3885096,12.8154753 C11.7343147,13.7054768 11.9254737,14.9572954 12.8154753,15.6114904 C13.7054768,16.2656853 14.9572954,16.0745263 15.6114904,15.1845247 Z"
|
||||
id="Eyebrow"
|
||||
/>
|
||||
<path
|
||||
d="M73.6114904,15.1845247 C77.8515017,9.41618792 80.4892046,9.70087612 86.9238518,14.5564693 C87.1057771,14.6937504 87.2212592,14.7812575 87.5936891,15.063789 C92.4216439,18.7263562 94.7081807,20 98,20 C99.1045695,20 100,19.1045695 100,18 C100,16.8954305 99.1045695,16 98,16 C95.9337712,16 94.0986396,14.9777974 90.011227,11.8770179 C89.6358269,11.5922331 89.5189458,11.5036659 89.3332441,11.3635351 C85.5737397,8.52660822 83.3739873,7.28738405 80.6379899,6.99208688 C76.9538127,6.59445233 73.5799484,8.47367246 70.3885096,12.8154753 C69.7343147,13.7054768 69.9254737,14.9572954 70.8154753,15.6114904 C71.7054768,16.2656853 72.9572954,16.0745263 73.6114904,15.1845247 Z"
|
||||
id="Eyebrow"
|
||||
transform="translate(84.999934, 13.470064) scale(-1, 1) translate(-84.999934, -13.470064) "
|
||||
/>
|
||||
</g>
|
||||
"""),
|
||||
AngryNatural("""
|
||||
<g id="Eyebrows/AngryNatural" fillOpacity="0.599999964">
|
||||
<path
|
||||
d="M44.8565785,12.2282877 C44.8578785,12.2192877 44.8578785,12.2192877 44.8565785,12.2282877 M17.5862288,7.89238094 C15.2441598,8.3302947 13.0866155,9.78806858 12.1523766,12.0987479 C11.8009169,12.967391 11.3917103,14.9243181 11.7083227,15.8073302 C11.8284629,16.14295 12.0332321,16.1008692 12.9555234,16.0430509 C14.643791,15.9369937 16.9330912,13.6622369 18.7484684,13.2557982 C21.2753939,12.6899315 23.9825295,13.1148447 26.4961798,13.6882381 C30.8109365,14.6725177 36.4854008,17.7875215 40.9461842,16.1699775 C41.2783949,16.0495512 45.6210294,12.9225732 44.3685187,12.2769925 C43.9238011,11.9068186 41.1370145,12.0854053 40.6216067,11.9988489 C38.2277647,11.5971998 35.7297127,10.9345131 33.373373,10.3265657 C28.2329017,9.00016592 22.9666484,6.88073171 17.5862288,7.89238094"
|
||||
id="Eyebrows-The-Web"
|
||||
transform="translate(28.094701, 12.127505) rotate(17.000000) translate(-28.094701, -12.127505) "
|
||||
/>
|
||||
<path
|
||||
d="M100.918293,12.2094196 C100.919593,12.2004196 100.919593,12.2004196 100.918293,12.2094196 M73.5862288,7.89238094 C71.2441598,8.3302947 69.0866155,9.78806858 68.1523766,12.0987479 C67.8009169,12.967391 67.3917103,14.9243181 67.7083227,15.8073302 C67.8284629,16.14295 68.0332321,16.1008692 68.9555234,16.0430509 C70.643791,15.9369937 72.9330912,13.6622369 74.7484684,13.2557982 C77.2753939,12.6899315 79.9825295,13.1148447 82.4961798,13.6882381 C86.8109365,14.6725177 92.4854008,17.7875215 96.9461842,16.1699775 C97.2783949,16.0495512 101.621029,12.9225732 100.368519,12.2769925 C99.9238011,11.9068186 97.1370145,12.0854053 96.6216067,11.9988489 C94.2277647,11.5971998 91.7297127,10.9345131 89.373373,10.3265657 C84.2329017,9.00016592 78.9666484,6.88073171 73.5862288,7.89238094"
|
||||
id="Eyebrows-The-Web"
|
||||
transform="translate(84.094701, 12.127505) scale(-1, 1) rotate(17.000000) translate(-84.094701, -12.127505) "
|
||||
/>
|
||||
</g>
|
||||
"""),
|
||||
Default("""
|
||||
<g id="Eyebrows/Default" fillOpacity="0.599999964">
|
||||
<g id="I-Browse" transform="translate(12.000000, 6.000000)">
|
||||
<path
|
||||
d="M3.63024536,11.1585767 C7.54515501,5.64986673 18.2779197,2.56083721 27.5230268,4.83118046 C28.5957248,5.0946055 29.6788665,4.43856013 29.9422916,3.36586212 C30.2057166,2.2931641 29.5496712,1.21002236 28.4769732,0.94659732 C17.7403633,-1.69001789 5.31209962,1.88699832 0.369754639,8.84142326 C-0.270109626,9.74178291 -0.0589363917,10.9903811 0.84142326,11.6302454 C1.74178291,12.2701096 2.9903811,12.0589364 3.63024536,11.1585767 Z"
|
||||
id="Eyebrow"
|
||||
fillRule="nonzero"
|
||||
/>
|
||||
<path
|
||||
d="M61.6302454,11.1585767 C65.545155,5.64986673 76.2779197,2.56083721 85.5230268,4.83118046 C86.5957248,5.0946055 87.6788665,4.43856013 87.9422916,3.36586212 C88.2057166,2.2931641 87.5496712,1.21002236 86.4769732,0.94659732 C75.7403633,-1.69001789 63.3120996,1.88699832 58.3697546,8.84142326 C57.7298904,9.74178291 57.9410636,10.9903811 58.8414233,11.6302454 C59.7417829,12.2701096 60.9903811,12.0589364 61.6302454,11.1585767 Z"
|
||||
id="Eyebrow"
|
||||
fillRule="nonzero"
|
||||
transform="translate(73.000154, 6.039198) scale(-1, 1) translate(-73.000154, -6.039198) "
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
"""),
|
||||
DefaultNatural("""
|
||||
<g id="Eyebrows/DefaultNatural" fillOpacity="0.599999964">
|
||||
<path
|
||||
d="M26.0390934,6.21012364 C20.2775554,6.98346216 11.2929313,12.0052479 12.04426,17.8178111 C12.0689481,18.0080543 12.3567302,18.0673468 12.4809077,17.9084937 C14.9674041,14.7203351 34.1927973,10.0365481 41.1942673,11.0147151 C41.8350523,11.1044465 42.2580662,10.4430343 41.8210501,10.0302067 C38.0765663,6.49485426 31.2003792,5.51224825 26.0390934,6.21012364"
|
||||
id="Eyebrow"
|
||||
transform="translate(27.000000, 12.000000) rotate(5.000000) translate(-27.000000, -12.000000) "
|
||||
/>
|
||||
<path
|
||||
d="M85.0390934,6.21012364 C79.2775554,6.98346216 70.2929313,12.0052479 71.04426,17.8178111 C71.0689481,18.0080543 71.3567302,18.0673468 71.4809077,17.9084937 C73.9674041,14.7203351 93.1927973,10.0365481 100.194267,11.0147151 C100.835052,11.1044465 101.258066,10.4430343 100.82105,10.0302067 C97.0765663,6.49485426 90.2003792,5.51224825 85.0390934,6.21012364"
|
||||
id="Eyebrow"
|
||||
transform="translate(86.000000, 12.000000) scale(-1, 1) rotate(5.000000) translate(-86.000000, -12.000000) "
|
||||
/>
|
||||
</g>
|
||||
"""),
|
||||
FlatNatural("""
|
||||
<g id="Eyebrows/FlatNatural" fillOpacity="0.599999964">
|
||||
<path
|
||||
d="M38.5686071,10.7022978 C33.5865557,11.2384494 28.6553385,11.1338998 23.6562444,11.1010606 C19.8231061,11.0762636 15.91974,10.6892291 12.3246118,12.5091287 C11.6361455,12.8572921 7.8767609,14.9449324 8.00311195,16.0108688 C8.10389896,16.8633498 12.0128479,18.0636592 12.7165939,18.2838164 C16.4280826,19.4452548 19.9241869,18.9282036 23.6870976,18.5703225 C28.3024371,18.1316834 32.9139567,18.1745756 37.5322346,17.8739956 C40.6422336,17.6719334 45.4224171,16.9769469 46.8293214,13.1484895 C47.2530382,11.9954284 46.8152171,9.73353891 46.3074622,8.50642195 C46.1050066,8.01751871 45.5634602,7.84963624 45.1688335,8.14921095 C43.7560524,9.22218432 40.9851444,10.4425994 38.5686071,10.7022978"
|
||||
id="Fill-10"
|
||||
transform="translate(27.500000, 13.500000) rotate(2.000000) translate(-27.500000, -13.500000) "
|
||||
/>
|
||||
<path
|
||||
d="M95.5686071,10.7022978 C90.5865557,11.2384494 85.6553385,11.1338998 80.6562444,11.1010606 C76.8231061,11.0762636 72.91974,10.6892291 69.3246118,12.5091287 C68.6361455,12.8572921 64.8767609,14.9449324 65.003112,16.0108688 C65.103899,16.8633498 69.0128479,18.0636592 69.7165939,18.2838164 C73.4280826,19.4452548 76.9241869,18.9282036 80.6870976,18.5703225 C85.3024371,18.1316834 89.9139567,18.1745756 94.5322346,17.8739956 C97.6422336,17.6719334 102.422417,16.9769469 103.829321,13.1484895 C104.253038,11.9954284 103.815217,9.73353891 103.307462,8.50642195 C103.105007,8.01751871 102.56346,7.84963624 102.168833,8.14921095 C100.756052,9.22218432 97.9851444,10.4425994 95.5686071,10.7022978"
|
||||
id="Fill-10"
|
||||
transform="translate(84.500000, 13.500000) scale(-1, 1) rotate(2.000000) translate(-84.500000, -13.500000) "
|
||||
/>
|
||||
</g>
|
||||
"""),
|
||||
FrownNatural("""
|
||||
<g id="Eyebrows/FrownNatural" fillOpacity="0.599999964">
|
||||
<path
|
||||
d="M36.3692975,6.87618545 C34.3991755,9.78053246 30.8236346,11.5165625 27.6315757,12.5601676 C23.6890255,13.8490851 9.08080143,15.9390364 12.5196198,23.9079177 C12.572332,24.029546 12.7390347,24.0312591 12.7920764,23.9096308 C13.9448284,21.2646433 30.256648,18.7865093 31.7648785,18.2064622 C36.2101722,16.4974987 40.1579937,12.7153722 40.9269343,7.66282939 C41.2794477,5.34640965 40.2901039,1.6143049 39.3791695,0.113308759 C39.2697915,-0.0669067099 39.0052417,-0.02339461 38.9498938,0.181831751 C38.5898029,1.51323348 37.5385221,5.15317482 36.3692975,6.87618545"
|
||||
id="Fill-5"
|
||||
/>
|
||||
<path
|
||||
d="M95.3692975,6.87618545 C93.3991755,9.78053246 89.8236346,11.5165625 86.6315757,12.5601676 C82.6890255,13.8490851 68.0808014,15.9390364 71.5196198,23.9079177 C71.572332,24.029546 71.7390347,24.0312591 71.7920764,23.9096308 C72.9448284,21.2646433 89.256648,18.7865093 90.7648785,18.2064622 C95.2101722,16.4974987 99.1579937,12.7153722 99.9269343,7.66282939 C100.279448,5.34640965 99.2901039,1.6143049 98.3791695,0.113308759 C98.2697915,-0.0669067099 98.0052417,-0.02339461 97.9498938,0.181831751 C97.5898029,1.51323348 96.5385221,5.15317482 95.3692975,6.87618545"
|
||||
id="Fill-5"
|
||||
transform="translate(85.500000, 12.000000) scale(-1, 1) translate(-85.500000, -12.000000) "
|
||||
/>
|
||||
</g>
|
||||
"""),
|
||||
RaisedExcited("""
|
||||
<g id="Eyebrows/RaisedExcited" fillOpacity="0.599999964">
|
||||
<g id="I-Browse" transform="translate(12.000000, 0.000000)">
|
||||
<path
|
||||
d="M3.97579559,17.1279169 C5.47099148,7.60476158 18.0585488,1.10867597 27.1635167,5.30104271 C28.1668367,5.76301969 29.3546946,5.32417444 29.8166716,4.32085442 C30.2786486,3.3175344 29.8398033,2.12967649 28.8364833,1.66769952 C17.3488212,-3.62177466 1.93575948,4.3324746 0.0242044059,16.507492 C-0.147121205,17.5986938 0.598585765,18.6221744 1.68978754,18.7935 C2.78098932,18.9648257 3.80446998,18.2191187 3.97579559,17.1279169 Z"
|
||||
id="Eyebrow"
|
||||
fillRule="nonzero"
|
||||
/>
|
||||
<path
|
||||
d="M61.9757956,17.1279169 C63.4709915,7.60476158 76.0585488,1.10867597 85.1635167,5.30104271 C86.1668367,5.76301969 87.3546946,5.32417444 87.8166716,4.32085442 C88.2786486,3.3175344 87.8398033,2.12967649 86.8364833,1.66769952 C75.3488212,-3.62177466 59.9357595,4.3324746 58.0242044,16.507492 C57.8528788,17.5986938 58.5985858,18.6221744 59.6897875,18.7935 C60.7809893,18.9648257 61.80447,18.2191187 61.9757956,17.1279169 Z"
|
||||
id="Eyebrow"
|
||||
fillRule="nonzero"
|
||||
transform="translate(73.000097, 9.410436) scale(-1, 1) translate(-73.000097, -9.410436) "
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
"""),
|
||||
RaisedExcitedNatural("""
|
||||
<g id="Eyebrows/RaisedExcitedNatural" fillOpacity="0.599999964">
|
||||
<path
|
||||
d="M22.7663531,1.57844898 L23.6772984,1.17582144 C28.9190996,-0.905265751 36.8645466,-0.0328729562 41.7227321,2.29911638 C42.2897848,2.57148957 41.9021563,3.4519421 41.3211012,3.40711006 C26.4021788,2.25602197 16.3582869,11.5525942 12.9460869,17.8470939 C12.8449215,18.0337142 12.5391523,18.05489 12.4635344,17.8808353 C10.156283,12.5620676 16.9134476,3.89614725 22.7663531,1.57844898 Z"
|
||||
id="Eye-Browse-Reddit"
|
||||
/>
|
||||
<path
|
||||
d="M80.7663531,1.57844898 L81.6772984,1.17582144 C86.9190996,-0.905265751 94.8645466,-0.0328729562 99.7227321,2.29911638 C100.289785,2.57148957 99.9021563,3.4519421 99.3211012,3.40711006 C84.4021788,2.25602197 74.3582869,11.5525942 70.9460869,17.8470939 C70.8449215,18.0337142 70.5391523,18.05489 70.4635344,17.8808353 C68.156283,12.5620676 74.9134476,3.89614725 80.7663531,1.57844898 Z"
|
||||
id="Eye-Browse-Reddit"
|
||||
transform="translate(85.000000, 9.000000) scale(-1, 1) translate(-85.000000, -9.000000) "
|
||||
/>
|
||||
</g>
|
||||
"""),
|
||||
SadConcerned("""
|
||||
<g
|
||||
id="Eyebrows/SadConcerned"
|
||||
fillOpacity="0.599999964"
|
||||
fillRule="nonzero">
|
||||
<path
|
||||
d="M15.9726042,19.4088529 C17.452356,11.0203704 30.0622688,5.22829657 39.2106453,8.9774793 C40.2254706,9.39337449 41.4016967,8.94600219 41.8378196,7.97824531 C42.2739426,7.01048842 41.8048116,5.88881678 40.7899862,5.47292159 C29.3457328,0.782843812 13.9550264,7.85221132 12.0280273,18.7760684 C11.84479,19.8148122 12.5792704,20.798534 13.6685352,20.9732726 C14.7578,21.1480113 15.7893668,20.4475967 15.9726042,19.4088529 Z"
|
||||
id="Eyebrow"
|
||||
transform="translate(27.000414, 12.500000) scale(-1, -1) translate(-27.000414, -12.500000) "
|
||||
/>
|
||||
<path
|
||||
d="M73.9726042,19.4088529 C75.452356,11.0203704 88.0622688,5.22829657 97.2106453,8.9774793 C98.2254706,9.39337449 99.4016967,8.94600219 99.8378196,7.97824531 C100.273943,7.01048842 99.8048116,5.88881678 98.7899862,5.47292159 C87.3457328,0.782843812 71.9550264,7.85221132 70.0280273,18.7760684 C69.84479,19.8148122 70.5792704,20.798534 71.6685352,20.9732726 C72.7578,21.1480113 73.7893668,20.4475967 73.9726042,19.4088529 Z"
|
||||
id="Eyebrow"
|
||||
transform="translate(85.000414, 12.500000) scale(1, -1) translate(-85.000414, -12.500000) "
|
||||
/>
|
||||
</g>
|
||||
"""),
|
||||
SadConcernedNatural("""
|
||||
<g id="Eyebrows/SadConcernedNatural" fillOpacity="0.599999964">
|
||||
<path
|
||||
d="M22.7663531,5.57844898 L23.6772984,5.17582144 C28.9190996,3.09473425 36.8645466,3.96712704 41.7227321,6.29911638 C42.2897848,6.57148957 41.9021563,7.4519421 41.3211012,7.40711006 C26.4021788,6.25602197 16.3582869,15.5525942 12.9460869,21.8470939 C12.8449215,22.0337142 12.5391523,22.05489 12.4635344,21.8808353 C10.156283,16.5620676 16.9134476,7.89614725 22.7663531,5.57844898 Z"
|
||||
id="Eyebrow"
|
||||
transform="translate(27.000000, 13.000000) scale(-1, -1) translate(-27.000000, -13.000000) "
|
||||
/>
|
||||
<path
|
||||
d="M80.7663531,5.57844898 L81.6772984,5.17582144 C86.9190996,3.09473425 94.8645466,3.96712704 99.7227321,6.29911638 C100.289785,6.57148957 99.9021563,7.4519421 99.3211012,7.40711006 C84.4021788,6.25602197 74.3582869,15.5525942 70.9460869,21.8470939 C70.8449215,22.0337142 70.5391523,22.05489 70.4635344,21.8808353 C68.156283,16.5620676 74.9134476,7.89614725 80.7663531,5.57844898 Z"
|
||||
id="Eyebrow"
|
||||
transform="translate(85.000000, 13.000000) scale(1, -1) translate(-85.000000, -13.000000) "
|
||||
/>
|
||||
</g>
|
||||
"""),
|
||||
UnibrowNatural("""<g id="Eyebrows/UnibrowNatural" fillOpacity="0.599999964">
|
||||
<path
|
||||
d="M57.000525,12 C56.999825,11.9961 56.999825,11.9961 57.000525,12 M59.4596631,14.892451 C61.3120123,16.058698 64.1131185,16.7894891 65.7030886,17.0505179 C71.9486685,18.0766191 78.0153663,15.945512 84.1715051,15.0153209 C89.636055,14.1895424 95.8563653,13.4967455 100.86041,16.507708 C100.987756,16.584232 101.997542,17.2147893 102.524546,17.7511372 C102.91024,18.1443003 103.563259,18.0619945 103.822605,17.5722412 C105.241692,14.8939029 97.7243204,8.76008291 96.2812935,8.14993193 C89.7471082,5.39200867 81.0899445,8.32440654 74.4284137,9.38927986 C70.6888462,9.98718701 66.9279989,10.3803501 63.2409655,11.2908151 C61.9188284,11.6171635 60.6278928,12.2066818 59.3382119,12.3724317 C59.1848981,12.1429782 58.9889964,12 58.7633758,12 C57.5922879,12 55.8451696,15.4574504 58.0750241,15.6547468 C58.7728345,15.7164887 59.215997,15.3816732 59.4596631,14.892451 Z"
|
||||
id="Kahlo"
|
||||
transform="translate(80.500000, 12.500000) rotate(-2.000000) translate(-80.500000, -12.500000) "
|
||||
/>
|
||||
<path
|
||||
d="M54.999475,12 C55.000175,11.9961 55.000175,11.9961 54.999475,12 M15.7187065,8.14993193 C22.2528918,5.39200867 30.9100555,8.32440654 37.5715863,9.38927986 C41.3111538,9.98718701 45.0720011,10.3803501 48.7590345,11.2908151 C50.2416282,11.6567696 51.6849876,12.3536477 53.1313394,12.4128263 C53.8325707,12.4413952 54.2674737,13.2763566 53.8149484,13.8242681 C52.3320222,15.6179895 48.3271239,16.7172136 46.2969114,17.0505179 C40.0513315,18.0766191 33.9846337,15.945512 27.8284949,15.0153209 C22.363945,14.1895424 16.1436347,13.4967455 11.1395899,16.507708 C11.0122444,16.584232 10.0024581,17.2147893 9.47545402,17.7511372 C9.0897602,18.1443003 8.43674067,18.0619945 8.17739482,17.5722412 C6.75830756,14.8939029 14.2756796,8.76008291 15.7187065,8.14993193 Z M54.9339874,11 C56.1050753,11 57.8521936,15.4015737 55.6223391,15.6527457 C53.3924847,15.9039176 53.7628995,11 54.9339874,11 Z"
|
||||
id="Frida"
|
||||
transform="translate(32.348682, 12.500000) rotate(2.000000) translate(-32.348682, -12.500000) "
|
||||
/>
|
||||
</g>
|
||||
"""),
|
||||
UpDown("""<g
|
||||
id="Eyebrows/UpDown"
|
||||
fillOpacity="0.599999964"
|
||||
fillRule="nonzero">
|
||||
<path
|
||||
d="M15.5914402,14.1619718 C20.0874642,7.83556966 29.6031809,4.65350252 39.3473715,7.79575991 C40.3986323,8.13476518 41.5256656,7.55736801 41.8646708,6.50610724 C42.2036761,5.45484647 41.6262789,4.32781316 40.5750182,3.98880789 C29.1665516,0.309863172 17.8358054,4.09887835 12.3309495,11.8448183 C11.6910852,12.7451779 11.9022584,13.9937761 12.8026181,14.6336404 C13.7029777,15.2735046 14.9515759,15.0623314 15.5914402,14.1619718 Z"
|
||||
id="Eyebrow"
|
||||
/>
|
||||
<path
|
||||
d="M73.6376405,21.1577995 C77.5525501,15.6490895 88.2853148,12.56006 97.5304219,14.8304032 C98.6031199,15.0938282 99.6862617,14.4377829 99.9496867,13.3650849 C100.213112,12.2923868 99.5570664,11.2092451 98.4843684,10.9458201 C87.7477584,8.30920485 75.3194947,11.8862211 70.3771498,18.840646 C69.7372855,19.7410057 69.9484587,20.9896038 70.8488184,21.6294681 C71.749178,22.2693324 72.9977762,22.0581591 73.6376405,21.1577995 Z"
|
||||
id="Eyebrow"
|
||||
transform="translate(85.007549, 16.038421) scale(-1, 1) translate(-85.007549, -16.038421) "
|
||||
/>
|
||||
</g>
|
||||
"""),
|
||||
UpDownNatural(""" <g id="Eyebrows/UpDownNatural" fillOpacity="0.599999964">
|
||||
<path
|
||||
d="M22.7663531,1.57844898 L23.6772984,1.17582144 C28.9190996,-0.905265751 36.8645466,-0.0328729562 41.7227321,2.29911638 C42.2897848,2.57148957 41.9021563,3.4519421 41.3211012,3.40711006 C26.4021788,2.25602197 16.3582869,11.5525942 12.9460869,17.8470939 C12.8449215,18.0337142 12.5391523,18.05489 12.4635344,17.8808353 C10.156283,12.5620676 16.9134476,3.89614725 22.7663531,1.57844898 Z"
|
||||
id="Eye-Browse-Reddit"
|
||||
/>
|
||||
<path
|
||||
d="M86.535177,12.0246305 C92.3421916,12.2928751 101.730304,16.5124899 101.488432,22.3684172 C101.480419,22.5600881 101.1989,22.6442368 101.06135,22.496811 C98.306449,19.5374968 78.7459953,16.5471364 71.8564209,18.1317995 C71.2258949,18.2770375 70.7468448,17.6550104 71.1462176,17.2056651 C74.5683263,13.3574126 81.3327077,11.7792465 86.535177,12.0246305 Z"
|
||||
id="Eyebrow"
|
||||
transform="translate(86.246508, 17.285912) rotate(5.000000) translate(-86.246508, -17.285912) "
|
||||
/>
|
||||
</g>
|
||||
""");
|
||||
|
||||
final String svg;
|
||||
|
||||
const Eyebrows(this.svg);
|
||||
|
||||
String get label => name;
|
||||
String get id => "Eyebrows/$name";
|
||||
String get value => svg;
|
||||
}
|
||||
233
avatar_maker/lib/src/core/enums/property_items/eyes.dart
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
import "package:avatar_maker/src/core/models/property_item.dart";
|
||||
|
||||
/// List of all the eyes displayed by default.
|
||||
enum Eyes implements PropertyItem {
|
||||
Closed("""
|
||||
<g
|
||||
id="Eyes/Closed"
|
||||
transform="translate(0.000000, 8.000000)"
|
||||
fillOpacity="0.599999964">
|
||||
<path
|
||||
d="M16.1601674,32.4473116 C18.006676,28.648508 22.1644225,26 26.9975803,26 C31.8136766,26 35.9591217,28.629842 37.8153518,32.4071242 C38.3667605,33.5291977 37.5821037,34.4474817 36.790607,33.7670228 C34.3395063,31.6597833 30.8587163,30.3437884 26.9975803,30.3437884 C23.2572061,30.3437884 19.8737584,31.5787519 17.4375392,33.5716412 C16.5467928,34.3002944 15.6201012,33.5583844 16.1601674,32.4473116 Z"
|
||||
id="Closed-Eye"
|
||||
transform="translate(27.000000, 30.000000) scale(1, -1) translate(-27.000000, -30.000000) "
|
||||
/>
|
||||
<path
|
||||
d="M74.1601674,32.4473116 C76.006676,28.648508 80.1644225,26 84.9975803,26 C89.8136766,26 93.9591217,28.629842 95.8153518,32.4071242 C96.3667605,33.5291977 95.5821037,34.4474817 94.790607,33.7670228 C92.3395063,31.6597833 88.8587163,30.3437884 84.9975803,30.3437884 C81.2572061,30.3437884 77.8737584,31.5787519 75.4375392,33.5716412 C74.5467928,34.3002944 73.6201012,33.5583844 74.1601674,32.4473116 Z"
|
||||
id="Closed-Eye"
|
||||
transform="translate(85.000000, 30.000000) scale(1, -1) translate(-85.000000, -30.000000) "
|
||||
/>
|
||||
</g>
|
||||
"""),
|
||||
Cry("""
|
||||
<g id="Eyes/Cry" transform="translate(0.000000, 8.000000)">
|
||||
<circle
|
||||
id="Eye"
|
||||
fillOpacity="0.599999964"
|
||||
fill="#000000"
|
||||
fillRule="evenodd"
|
||||
cx="30"
|
||||
cy="22"
|
||||
r="6"
|
||||
/>
|
||||
<path
|
||||
d="M25,27 C25,27 19,34.2706667 19,38.2706667 C19,41.5846667 21.686,44.2706667 25,44.2706667 C28.314,44.2706667 31,41.5846667 31,38.2706667 C31,34.2706667 25,27 25,27 Z"
|
||||
id="Drop"
|
||||
fill="#92D9FF"
|
||||
fillRule="nonzero"
|
||||
/>
|
||||
<circle
|
||||
id="Eye"
|
||||
fillOpacity="0.599999964"
|
||||
fill="#000000"
|
||||
fillRule="evenodd"
|
||||
cx="82"
|
||||
cy="22"
|
||||
r="6"
|
||||
/>
|
||||
</g>
|
||||
"""),
|
||||
Default("""
|
||||
<g
|
||||
id="Eyes/Default"
|
||||
transform="translate(0.000000, 8.000000)"
|
||||
fillOpacity="0.599999964">
|
||||
<circle id="Eye" cx="30" cy="22" r="6" />
|
||||
<circle id="Eye" cx="82" cy="22" r="6" />
|
||||
</g>
|
||||
"""),
|
||||
Dizzy("""
|
||||
<g
|
||||
id="Eyes/Dizzy"
|
||||
transform="translate(0.000000, 8.000000)"
|
||||
fillOpacity="0.599999964"
|
||||
fillRule="nonzero">
|
||||
<path
|
||||
d="M29,25.2 L34.5,30.7 C35,31.1 35.7,31.1 36.1,30.7 L37.7,29.1 C38.1,28.6 38.1,27.9 37.7,27.5 L32.2,22 L37.7,16.5 C38.1,16 38.1,15.3 37.7,14.9 L36.1,13.3 C35.6,12.9 34.9,12.9 34.5,13.3 L29,18.8 L23.5,13.3 C23,12.9 22.3,12.9 21.9,13.3 L20.3,14.9 C19.9,15.3 19.9,16 20.3,16.5 L25.8,22 L20.3,27.5 C19.9,28 19.9,28.7 20.3,29.1 L21.9,30.7 C22.4,31.1 23.1,31.1 23.5,30.7 L29,25.2 Z"
|
||||
id="Eye"
|
||||
/>
|
||||
<path
|
||||
d="M83,25.2 L88.5,30.7 C89,31.1 89.7,31.1 90.1,30.7 L91.7,29.1 C92.1,28.6 92.1,27.9 91.7,27.5 L86.2,22 L91.7,16.5 C92.1,16 92.1,15.3 91.7,14.9 L90.1,13.3 C89.6,12.9 88.9,12.9 88.5,13.3 L83,18.8 L77.5,13.3 C77,12.9 76.3,12.9 75.9,13.3 L74.3,14.9 C73.9,15.3 73.9,16 74.3,16.5 L79.8,22 L74.3,27.5 C73.9,28 73.9,28.7 74.3,29.1 L75.9,30.7 C76.4,31.1 77.1,31.1 77.5,30.7 L83,25.2 Z"
|
||||
id="Eye"
|
||||
/>
|
||||
</g>
|
||||
"""),
|
||||
EyeRoll("""
|
||||
<g id="Eyes/EyeRoll" transform="translate(0.000000, 8.000000)">
|
||||
<circle id="Eyeball" fill="#FFFFFF" cx="30" cy="22" r="14" />
|
||||
<circle id="The-white-stuff" fill="#FFFFFF" cx="82" cy="22" r="14" />
|
||||
<circle
|
||||
id="Eye"
|
||||
fillOpacity="0.699999988"
|
||||
fill="#000000"
|
||||
cx="30"
|
||||
cy="14"
|
||||
r="6"
|
||||
/>
|
||||
<circle
|
||||
id="Eye"
|
||||
fillOpacity="0.699999988"
|
||||
fill="#000000"
|
||||
cx="82"
|
||||
cy="14"
|
||||
r="6"
|
||||
/>
|
||||
</g>
|
||||
"""),
|
||||
Happy("""
|
||||
<g
|
||||
id="Eyes/Happy"
|
||||
transform="translate(0.000000, 8.000000)"
|
||||
fillOpacity="0.599999964">
|
||||
<path
|
||||
d="M16.1601674,22.4473116 C18.006676,18.648508 22.1644225,16 26.9975803,16 C31.8136766,16 35.9591217,18.629842 37.8153518,22.4071242 C38.3667605,23.5291977 37.5821037,24.4474817 36.790607,23.7670228 C34.3395063,21.6597833 30.8587163,20.3437884 26.9975803,20.3437884 C23.2572061,20.3437884 19.8737584,21.5787519 17.4375392,23.5716412 C16.5467928,24.3002944 15.6201012,23.5583844 16.1601674,22.4473116 Z"
|
||||
id="Squint"
|
||||
/>
|
||||
<path
|
||||
d="M74.1601674,22.4473116 C76.006676,18.648508 80.1644225,16 84.9975803,16 C89.8136766,16 93.9591217,18.629842 95.8153518,22.4071242 C96.3667605,23.5291977 95.5821037,24.4474817 94.790607,23.7670228 C92.3395063,21.6597833 88.8587163,20.3437884 84.9975803,20.3437884 C81.2572061,20.3437884 77.8737584,21.5787519 75.4375392,23.5716412 C74.5467928,24.3002944 73.6201012,23.5583844 74.1601674,22.4473116 Z"
|
||||
id="Squint"
|
||||
/>
|
||||
</g>
|
||||
"""),
|
||||
Hearts("""
|
||||
<g
|
||||
id="Eyes/Hearts"
|
||||
transform="translate(0.000000, 8.000000)"
|
||||
fillOpacity="0.8"
|
||||
fillRule="nonzero"
|
||||
fill="#FF5353">
|
||||
<path
|
||||
d="M35.9583333,10 C33.4074091,10 30.8837273,11.9797894 29.5,13.8206358 C28.1106364,11.9797894 25.5925909,10 23.0416667,10 C17.5523182,10 14,13.3341032 14,17.6412715 C14,23.3708668 18.4118636,26.771228 23.0416667,30.376724 C24.695,31.6133636 27.8223436,34.7777086 28.2083333,35.470905 C28.5943231,36.1641015 30.3143077,36.1885229 30.7916667,35.470905 C31.2690257,34.7532872 34.3021818,31.6133636 35.9583333,30.376724 C40.5853182,26.771228 45,23.3708668 45,17.6412715 C45,13.3341032 41.4476818,10 35.9583333,10 Z"
|
||||
id="Heart"
|
||||
/>
|
||||
<path
|
||||
d="M88.9583333,10 C86.4074091,10 83.8837273,11.9797894 82.5,13.8206358 C81.1106364,11.9797894 78.5925909,10 76.0416667,10 C70.5523182,10 67,13.3341032 67,17.6412715 C67,23.3708668 71.4118636,26.771228 76.0416667,30.376724 C77.695,31.6133636 80.8223436,34.7777086 81.2083333,35.470905 C81.5943231,36.1641015 83.3143077,36.1885229 83.7916667,35.470905 C84.2690257,34.7532872 87.3021818,31.6133636 88.9583333,30.376724 C93.5853182,26.771228 98,23.3708668 98,17.6412715 C98,13.3341032 94.4476818,10 88.9583333,10 Z"
|
||||
id="Heart"
|
||||
/>
|
||||
</g>
|
||||
"""),
|
||||
Side("""
|
||||
<g
|
||||
id="Eyes/Side"
|
||||
transform="translate(0.000000, 8.000000)"
|
||||
fillOpacity="0.599999964">
|
||||
<path
|
||||
d="M27.2409577,20.3455337 C26.462715,21.3574913 26,22.6247092 26,24 C26,27.3137085 28.6862915,30 32,30 C35.3137085,30 38,27.3137085 38,24 C38,23.7097898 37.9793961,23.4243919 37.9395713,23.1451894 C37.9474218,22.9227843 37.9097825,22.6709538 37.8153518,22.4071242 C37.7703692,22.2814477 37.7221152,22.1572512 37.6706873,22.0345685 C37.3370199,21.0717264 36.7650456,20.2202109 36.0253277,19.550585 C33.898886,17.3173253 30.5064735,16 26.9975803,16 C22.1644225,16 18.006676,18.648508 16.1601674,22.4473116 C15.6201012,23.5583844 16.5467928,24.3002944 17.4375392,23.5716412 C19.8737584,21.5787519 23.2572061,20.3437884 26.9975803,20.3437884 C27.0788767,20.3437884 27.1600045,20.3443718 27.2409577,20.3455337 Z"
|
||||
id="Eye"
|
||||
/>
|
||||
<path
|
||||
d="M85.2409577,20.3455337 C84.462715,21.3574913 84,22.6247092 84,24 C84,27.3137085 86.6862915,30 90,30 C93.3137085,30 96,27.3137085 96,24 C96,23.7097898 95.9793961,23.4243919 95.9395713,23.1451894 C95.9474218,22.9227843 95.9097825,22.6709538 95.8153518,22.4071242 C95.7703692,22.2814477 95.7221152,22.1572512 95.6706873,22.0345685 C95.3370199,21.0717264 94.7650456,20.2202109 94.0253277,19.550585 C91.898886,17.3173253 88.5064735,16 84.9975803,16 C80.1644225,16 76.006676,18.648508 74.1601674,22.4473116 C73.6201012,23.5583844 74.5467928,24.3002944 75.4375392,23.5716412 C77.8737584,21.5787519 81.2572061,20.3437884 84.9975803,20.3437884 C85.0788767,20.3437884 85.1600045,20.3443718 85.2409577,20.3455337 Z"
|
||||
id="Eye"
|
||||
/>
|
||||
</g>
|
||||
"""),
|
||||
Surprised("""
|
||||
<g id="Eyes/Surprised" transform="translate(0.000000, 8.000000)">
|
||||
<circle id="The-White-Stuff" fill="#FFFFFF" cx="30" cy="22" r="14" />
|
||||
<circle id="Eye-Ball" fill="#FFFFFF" cx="82" cy="22" r="14" />
|
||||
<circle
|
||||
id="Eye"
|
||||
fillOpacity="0.699999988"
|
||||
fill="#000000"
|
||||
cx="30"
|
||||
cy="22"
|
||||
r="6"
|
||||
/>
|
||||
<circle
|
||||
id="Eye"
|
||||
fillOpacity="0.699999988"
|
||||
fill="#000000"
|
||||
cx="82"
|
||||
cy="22"
|
||||
r="6"
|
||||
/>
|
||||
</g>
|
||||
"""),
|
||||
Wink("""
|
||||
<g
|
||||
id="Eyes/Wink"
|
||||
transform="translate(0.000000, 8.000000)"
|
||||
fillOpacity="0.599999964">
|
||||
<circle id="Eye" cx="30" cy="22" r="6" />
|
||||
<path
|
||||
d="M70.4123979,24.204889 C72.2589064,20.4060854 76.4166529,17.7575774 81.2498107,17.7575774 C86.065907,17.7575774 90.2113521,20.3874194 92.0675822,24.1647016 C92.618991,25.2867751 91.8343342,26.2050591 91.0428374,25.5246002 C88.5917368,23.4173607 85.1109468,22.1013658 81.2498107,22.1013658 C77.5094365,22.1013658 74.1259889,23.3363293 71.6897696,25.3292186 C70.7990233,26.0578718 69.8723316,25.3159619 70.4123979,24.204889 Z"
|
||||
id="Winky-Wink"
|
||||
transform="translate(81.252230, 21.757577) rotate(-4.000000) translate(-81.252230, -21.757577) "
|
||||
/>
|
||||
</g>
|
||||
"""),
|
||||
WinkWacky("""
|
||||
<g id="Eyes/WinkWacky" transform="translate(0.000000, 8.000000)">
|
||||
<circle
|
||||
id="Cornea?-I-don't-know"
|
||||
fill="#FFFFFF"
|
||||
cx="82"
|
||||
cy="22"
|
||||
r="12"
|
||||
/>
|
||||
<circle
|
||||
id="Eye"
|
||||
fillOpacity="0.699999988"
|
||||
fill="#000000"
|
||||
cx="82"
|
||||
cy="22"
|
||||
r="6"
|
||||
/>
|
||||
<path
|
||||
d="M16.1601674,25.4473116 C18.006676,21.648508 22.1644225,19 26.9975803,19 C31.8136766,19 35.9591217,21.629842 37.8153518,25.4071242 C38.3667605,26.5291977 37.5821037,27.4474817 36.790607,26.7670228 C34.3395063,24.6597833 30.8587163,23.3437884 26.9975803,23.3437884 C23.2572061,23.3437884 19.8737584,24.5787519 17.4375392,26.5716412 C16.5467928,27.3002944 15.6201012,26.5583844 16.1601674,25.4473116 Z"
|
||||
id="Winky-Doodle"
|
||||
fillOpacity="0.599999964"
|
||||
fill="#000000"
|
||||
/>
|
||||
</g>
|
||||
"""),
|
||||
Squint("""
|
||||
<g id="Eyes/Squint" transform="translate(0.000000, 8.000000)">
|
||||
<defs>
|
||||
<path d="M14,14.0481187 C23.6099827,14.0481187 28,18.4994466 28,11.5617716 C28,4.62409673 21.7319865,0 14,0 C6.2680135,0 0,4.62409673 0,11.5617716 C0,18.4994466 4.39001726,14.0481187 14,14.0481187 Z" id="react-path-55832"></path>
|
||||
<path d="M14,14.0481187 C23.6099827,14.0481187 28,18.4994466 28,11.5617716 C28,4.62409673 21.7319865,0 14,0 C6.2680135,0 0,4.62409673 0,11.5617716 C0,18.4994466 4.39001726,14.0481187 14,14.0481187 Z" id="react-path-55833"></path>
|
||||
</defs>
|
||||
<g id="Eye" transform="translate(16.000000, 13.000000)">
|
||||
<mask id="react-mask-55834" fill="white">
|
||||
<use xlink:href="#react-path-55832"></use>
|
||||
</mask>
|
||||
<use id="The-white-stuff" fill="#FFFFFF" xlink:href="#react-path-55832"></use>
|
||||
<circle fill-opacity="0.699999988" fill="#000000" mask="url(#react-mask-55834)" cx="14" cy="10" r="6"></circle>
|
||||
</g>
|
||||
<g id="Eye" transform="translate(68.000000, 13.000000)">
|
||||
<mask id="react-mask-55835" fill="white">
|
||||
<use xlink:href="#react-path-55833"></use>
|
||||
</mask>
|
||||
<use id="Eyeball-Mask" fill="#FFFFFF" xlink:href="#react-path-55833"></use>
|
||||
<circle fill-opacity="0.699999988" fill="#000000" mask="url(#react-mask-55835)" cx="14" cy="10" r="6"></circle>
|
||||
</g>
|
||||
</g>""");
|
||||
|
||||
final String svg;
|
||||
|
||||
const Eyes(this.svg);
|
||||
|
||||
String get label => name;
|
||||
String get id => "Eyes/$name";
|
||||
String get value => svg;
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import "package:avatar_maker/src/core/models/property_item.dart";
|
||||
|
||||
/// List of all the facial hair colors displayed by default.
|
||||
enum FacialHairColors implements PropertyItem {
|
||||
Auburn("#A55728"),
|
||||
Black("#2C1B18"),
|
||||
Blonde("#B58143"),
|
||||
BlondeGolden("#D6B370"),
|
||||
Brown("#724133"),
|
||||
BrownDark("#4A312C"),
|
||||
PastelPink("#F59797"),
|
||||
Platinum("#ECDCBF"),
|
||||
Red("#C93305"),
|
||||
SilverGray("#E8E1E1"),
|
||||
DarkGray("#444444"),
|
||||
LightGray("#78909C"),
|
||||
Purple("#8E24AA"),
|
||||
Fuchsia("#D81B60"),
|
||||
Blue("#0277BD"),
|
||||
Green("#1B5E20");
|
||||
|
||||
final String hexCode;
|
||||
|
||||
const FacialHairColors(this.hexCode);
|
||||
|
||||
String get label => this.name;
|
||||
String get id => "FacialHairColor/$name";
|
||||
String get value => this.hexCode;
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
import "package:avatar_maker/src/core/enums/placeholders.dart";
|
||||
import "package:avatar_maker/src/core/models/property_item.dart";
|
||||
|
||||
/// List of all the facial hair types displayed by default.
|
||||
enum FacialHairTypes implements PropertyItem {
|
||||
Nothing(""),
|
||||
FullBeard("""
|
||||
<g id="FacialHair/FullBeard" transform="translate(-28.000000, -8.000000)">
|
||||
<defs>
|
||||
<path d="M105.017591,94.1296214 C101.150441,99.7213834 98.257542,95.9467308 94.1374777,92.8762163 C91.6567227,91.0272796 87.9608129,88.7275108 84.5044337,88.8410391 C81.0477114,88.7275108 77.3518016,91.0272796 74.8710466,92.8762163 C70.7509822,95.9467308 67.8580835,99.7213834 63.9909333,94.1296214 C61.0884259,89.9323547 62.3028943,82.8739117 65.014944,78.9027173 C68.8738581,73.2512381 74.1088724,75.9847769 79.9622738,75.3400279 C81.5538829,75.1648137 83.1526985,74.7228407 84.5044337,74 C85.856169,74.7228407 87.4546414,75.1648137 89.0462504,75.3400279 C94.899995,75.9847769 100.134666,73.2512381 103.993923,78.9027173 C106.70563,82.8739117 107.920098,89.9323547 105.017591,94.1296214 M140.39109,26 C136.966521,40.0748212 135.393023,54.4337754 132.909944,68.6711471 C132.392536,71.6390145 131.826063,74.5963095 131.224594,77.5496398 C131.098329,78.1697764 130.973781,80.4725746 130.362704,80.7643064 C128.511632,81.6484223 124.739149,76.9466834 123.730409,75.8851496 C121.196893,73.219256 118.684993,70.5292442 115.599415,68.437233 C109.364783,64.2102603 102.065485,61.7108818 94.4700836,61.117837 C91.2922091,60.8693859 86.9951134,61.3025234 84.000116,63.1104016 C81.0051185,61.3025234 76.7080229,60.8693859 73.5298053,61.117837 C65.9344039,61.7108818 58.6351055,64.2102603 52.4004739,68.437233 C49.3148957,70.5292442 46.8033387,73.219256 44.2694796,75.8851496 C43.2607395,76.9466834 39.4882573,81.6484223 37.6371849,80.7643064 C37.0261079,80.4725746 36.9015594,78.1697764 36.7752954,77.5496398 C36.1738255,74.5963095 35.6073527,71.6390145 35.0899445,68.6711471 C32.6072086,54.4337754 31.0337113,40.0748212 27.6091415,26 C26.6127533,26 25.7385119,44.7478165 25.6273446,46.4945731 C25.174784,53.5889755 24.6463963,60.5254529 25.3216346,67.6261326 C26.485803,79.8749043 27.6993791,95.2339402 37.032627,104.58753 C45.4659003,113.039493 57.7103052,114.806417 68.2713185,120.141327 C69.631059,120.828202 71.4347824,121.676306 73.3798667,122.37111 C75.4289129,123.934171 79.4926946,125 84.1740722,125 C89.0846465,125 93.3155222,123.827456 95.2540874,122.137856 C96.9548781,121.49261 98.5180822,120.752874 99.7285704,120.141327 C110.288776,114.805245 122.533989,113.039493 130.967262,104.58753 C140.30051,95.2339402 141.514086,79.8749043 142.678597,67.6261326 C143.353493,60.5254529 142.825105,53.5889755 142.372887,46.4945731 C142.261377,44.7478165 141.387136,26 140.39109,26 Z" id="react-path-20508"></path>
|
||||
</defs>
|
||||
<mask id="react-mask-20507" fill="white">
|
||||
<use xlink:href="#react-path-20508"></use>
|
||||
</mask>
|
||||
<use id="Beardness" fill="#252E32" fill-rule="evenodd" xlink:href="#react-path-20508"></use>
|
||||
<g id="$TO_REPLACE_WITH_FACIAL_HAIRS_COLOR_NAME" mask="url(#react-mask-20507)" fill="$TO_REPLACE_WITH_FACIAL_HAIRS_COLOR">
|
||||
<g transform="translate(-32.000000, 0.000000)" id="Color">
|
||||
<rect x="0" y="0" width="264" height="244"></rect>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
"""),
|
||||
BeardLight("""
|
||||
<g id="FacialHair/BeardLight" transform="translate(-28.000000, -8.000000)">
|
||||
<defs>
|
||||
<path d="M101.428403,98.1685688 C98.9148372,100.462621 96.23722,101.494309 92.8529444,100.772863 C92.2705777,100.648833 89.8963391,96.2345713 83.9998344,96.2345713 C78.1033297,96.2345713 75.7294253,100.648833 75.1467245,100.772863 C71.7624488,101.494309 69.0848316,100.462621 66.5712661,98.1685688 C61.8461772,93.855604 57.9166219,87.9081858 60.2778299,81.4191814 C61.5083844,78.0369425 63.5097479,74.3237342 67.1506257,73.2459109 C71.0384163,72.0955419 76.4968931,73.2439051 80.4147542,72.4582708 C81.6840664,72.2035248 83.0706538,71.7508657 83.9998344,71 C84.929015,71.7508657 86.3159365,72.2035248 87.5845805,72.4582708 C91.5027758,73.2439051 96.9612525,72.0955419 100.849043,73.2459109 C104.489921,74.3237342 106.491284,78.0369425 107.722173,81.4191814 C110.083381,87.9081858 106.153826,93.855604 101.428403,98.1685688 M140.081033,26 C136.670693,34.4002532 137.987774,44.8580348 137.356666,53.6758724 C136.844038,60.8431942 135.33712,71.5857526 128.972858,76.214531 C125.718361,78.5816138 119.79436,82.5598986 115.54187,81.4501943 C112.614539,80.6863848 112.302182,72.290096 108.455284,69.1469801 C104.09172,65.5823153 98.6429854,64.0160432 93.1491481,64.2578722 C90.7785381,64.3622683 85.9841367,64.3374908 83.9999331,66.1604584 C82.0157295,64.3374908 77.2216647,64.3622683 74.8510547,64.2578722 C69.3568808,64.0160432 63.9081467,65.5823153 59.5445817,69.1469801 C55.6976839,72.290096 55.3856641,80.6863848 52.4583326,81.4501943 C48.2058427,82.5598986 42.2818421,78.5816138 39.0270077,76.214531 C32.6624096,71.5857526 31.1561652,60.8431942 30.642864,53.6758724 C30.0120926,44.8580348 31.3291729,34.4002532 27.9188335,26 C26.2597768,26 27.3540339,42.1288693 27.3540339,42.1288693 L27.3540339,62.4851205 C27.3856735,77.7732046 36.935095,100.655445 58.1080116,109.393004 C63.2861266,111.52982 75.0153111,115 83.9999331,115 C92.9845551,115 104.71374,111.860188 109.891855,109.723371 C131.064771,100.985813 140.614193,77.7732046 140.646169,62.4851205 L140.646169,42.1288693 C140.646169,42.1288693 141.740089,26 140.081033,26" id="react-path-22754"></path>
|
||||
</defs>
|
||||
<mask id="react-mask-22753" fill="white">
|
||||
<use xlink:href="#react-path-22754"></use>
|
||||
</mask>
|
||||
<use id="Lite-Beard" fill="#331B0C" fill-rule="evenodd" xlink:href="#react-path-22754"></use>
|
||||
<g id="$TO_REPLACE_WITH_FACIAL_HAIRS_COLOR_NAME" mask="url(#react-mask-22753)" fill="$TO_REPLACE_WITH_FACIAL_HAIRS_COLOR">
|
||||
<g transform="translate(-32.000000, 0.000000)" id="Color">
|
||||
<rect x="0" y="0" width="264" height="244"></rect>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
"""),
|
||||
MoustacheFancy("""
|
||||
<g id="FacialHair/MoustacheFancy" transform="translate(-28.000000, -9.000000)">
|
||||
<defs>
|
||||
<path d="M84.0002865,69.2970648 C77.2083681,65.7112456 67.5782013,65.1489138 62.3885276,67.1316942 C56.6144416,69.3374281 51.5052994,75.5829845 42.6388201,72.8283797 C42.2699314,72.7136458 41.9094725,73.0449523 42.0204089,73.408662 C43.3937943,77.9183313 51.0278347,81.0068878 53.6221945,81.1080652 C64.961124,81.549609 74.0949802,72.8302891 84.0002865,72.1614794 C93.9055921,72.8302891 103.03945,81.549609 114.378714,81.1080652 C116.972736,81.0068878 124.607113,77.9183313 125.980498,73.408662 C126.091098,73.0449523 125.730639,72.7136458 125.36175,72.8283797 C116.495271,75.5829845 111.386129,69.3374281 105.612044,67.1316942 C100.422371,65.1489138 90.7922044,65.7112456 84.0002865,69.2970648 Z" id="react-path-23349"></path>
|
||||
</defs>
|
||||
<mask id="react-mask-23348" fill="white">
|
||||
<use xlink:href="#react-path-23349"></use>
|
||||
</mask>
|
||||
<use id="Moustache-U-a-Question" fill="#28354B" fill-rule="evenodd" xlink:href="#react-path-23349"></use>
|
||||
<g id="$TO_REPLACE_WITH_FACIAL_HAIRS_COLOR_NAME" mask="url(#react-mask-23348)" fill="$TO_REPLACE_WITH_FACIAL_HAIRS_COLOR">
|
||||
<g transform="translate(-32.000000, 0.000000)" id="Color">
|
||||
<rect x="0" y="0" width="264" height="244"></rect>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
"""),
|
||||
MoustacheMagnum("""
|
||||
<g id="FacialHair/MoustacheMagnum" transform="translate(-28.000000, -9.000000)">
|
||||
<defs>
|
||||
<path d="M83.9980103,74.839711 C83.4569991,75.6087366 82.761047,76.2496937 81.949688,76.6891498 C73.0477917,81.5102869 63.8767499,77.3322546 58.8763101,77.6298353 C56.459601,77.7739966 53.3405442,79.4153191 52.2155358,77.6791014 C50.9768736,75.7669804 55.0680827,65.2207224 64.7214121,63.4643353 C71.7310704,62.1893309 81.4972391,63.6024033 83.9980103,66.9380109 C86.4987814,63.6024033 96.2649453,62.1893309 103.274279,63.4643353 C112.927938,65.2207224 117.019147,75.7669804 115.780485,77.6791014 C114.655476,79.4153191 111.53642,77.7739966 109.119711,77.6298353 C104.118941,77.3322546 94.948229,81.5102869 86.0463327,76.6891498 C85.2349736,76.2496937 84.5390216,75.6087366 83.9980103,74.839711 Z" id="react-path-23820"></path>
|
||||
</defs>
|
||||
<mask id="react-mask-23819" fill="white">
|
||||
<use xlink:href="#react-path-23820"></use>
|
||||
</mask>
|
||||
<use id="Hey..." fill="#28354B" fill-rule="evenodd" xlink:href="#react-path-23820"></use>
|
||||
<g id="$TO_REPLACE_WITH_FACIAL_HAIRS_COLOR_NAME" mask="url(#react-mask-23819)" fill="$TO_REPLACE_WITH_FACIAL_HAIRS_COLOR">
|
||||
<g transform="translate(-32.000000, 0.000000)" id="Color">
|
||||
<rect x="0" y="0" width="264" height="244"></rect>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
""");
|
||||
|
||||
final String svg;
|
||||
|
||||
const FacialHairTypes(this.svg);
|
||||
|
||||
String get label => name;
|
||||
String get id => "FacialHair/$name";
|
||||
String get value => svg;
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import "package:avatar_maker/src/core/models/property_item.dart";
|
||||
|
||||
/// List of all the hair colors displayed by default.
|
||||
enum HairColors implements PropertyItem {
|
||||
Auburn("#A55728"),
|
||||
Black("#2C1B18"),
|
||||
Blonde("#B58143"),
|
||||
BlondeGolden("#D6B370"),
|
||||
Brown("#724133"),
|
||||
BrownDark("#4A312C"),
|
||||
PastelPink("#F59797"),
|
||||
Platinum("#ECDCBF"),
|
||||
Red("#C93305"),
|
||||
SilverGray("#E8E1E1"),
|
||||
DarkGray("#212121"),
|
||||
LightGray("#78909C"),
|
||||
Purple("#8E24AA"),
|
||||
Fuchsia("#D81B60"),
|
||||
Blue("#0277BD"),
|
||||
Green("#1B5E20");
|
||||
|
||||
final String hexCode;
|
||||
|
||||
const HairColors(this.hexCode);
|
||||
|
||||
String get label => this.name;
|
||||
String get id => "HairColor/$name";
|
||||
String get value => this.hexCode;
|
||||
}
|
||||
1165
avatar_maker/lib/src/core/enums/property_items/hair_styles.dart
Normal file
157
avatar_maker/lib/src/core/enums/property_items/mouths.dart
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import "package:avatar_maker/src/core/models/property_item.dart";
|
||||
|
||||
/// List of all the mouths displayed by default.
|
||||
enum Mouths implements PropertyItem {
|
||||
Concerned("""
|
||||
<g id="Mouth/Concerned" transform="translate(2.000000, 52.000000)">
|
||||
<defs>
|
||||
<path d="M35.117844,15.1280772 C36.1757121,24.6198025 44.2259873,32 54,32 C63.8042055,32 71.8740075,24.574136 72.8917593,15.0400546 C72.9736685,14.272746 72.1167429,13 71.042767,13 C56.1487536,13 44.7379213,13 37.0868244,13 C36.0066168,13 35.0120058,14.1784435 35.117844,15.1280772 Z" id="react-path-11322"></path>
|
||||
</defs>
|
||||
<mask id="react-mask-11323" fill="white">
|
||||
<use xlink:href="#react-path-11322" transform="translate(54.003637, 22.500000) scale(1, -1) translate(-54.003637, -22.500000) "></use>
|
||||
</mask>
|
||||
<use id="Mouth" fill-opacity="0.699999988" fill="#000000" fill-rule="evenodd" transform="translate(54.003637, 22.500000) scale(1, -1) translate(-54.003637, -22.500000) " xlink:href="#react-path-11322"></use>
|
||||
<rect id="Teeth" fill="#FFFFFF" fill-rule="evenodd" mask="url(#react-mask-11323)" x="39" y="2" width="31" height="16" rx="5"></rect>
|
||||
<g id="Tongue" stroke-width="1" fill-rule="evenodd" mask="url(#react-mask-11323)" fill="#FF4F6D">
|
||||
<g transform="translate(38.000000, 24.000000)">
|
||||
<circle id="friend?" cx="11" cy="11" r="11"></circle>
|
||||
<circle id="How-you-doing" cx="21" cy="11" r="11"></circle>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
"""),
|
||||
Default("""
|
||||
<g id="Mouth/Default" transform="translate(2.000000, 52.000000)" fill-opacity="0.699999988">
|
||||
<path d="M40,15 C40,22.7319865 46.2680135,29 54,29 L54,29 C61.7319865,29 68,22.7319865 68,15" id="Mouth"></path>
|
||||
</g>
|
||||
"""),
|
||||
Disbelief("""
|
||||
<g id="Mouth/Disbelief" transform="translate(2.000000, 52.000000)" fill-opacity="0.699999988" fill="#000000">
|
||||
<path d="M40,15 C40,22.7319865 46.2680135,29 54,29 L54,29 C61.7319865,29 68,22.7319865 68,15" id="Mouth" transform="translate(54.000000, 22.000000) scale(1, -1) translate(-54.000000, -22.000000) "></path>
|
||||
</g>
|
||||
"""),
|
||||
Eating("""
|
||||
<g id="Mouth/Eating" transform="translate(2.000000, 52.000000)">
|
||||
<g id="Om-Nom-Nom" opacity="0.599999964" stroke-width="1" transform="translate(28.000000, 6.000000)" fill-opacity="0.599999964" fill="#000000">
|
||||
<path d="M16.1906378,10.106319 C16.0179484,4.99553347 11.7923466,0.797193688 6.29352385,0 C9.66004124,1.95870633 11.9804619,5.49520667 11.9804619,9.67694348 C11.9804619,15.344608 6.50694731,20.2451296 0.176591694,20.2451296 C0.11761218,20.2451296 0.0587475828,20.2447983 0,20.244138 L8.8963743e-11,20.244138 C1.35764479,20.7317259 2.83995964,21 4.39225962,21 C9.71395931,21 14.2131224,17.8469699 15.6863572,13.5136402 C18.1609431,15.6698775 21.8629994,17.0394229 26,17.0394229 C30.1370006,17.0394229 33.8390569,15.6698775 36.3136428,13.5136402 C37.7868776,17.8469699 42.2860407,21 47.6077404,21 C49.1600404,21 50.6423552,20.7317259 52,20.244138 L52,20.244138 C51.9412524,20.2447983 51.8823878,20.2451296 51.8234083,20.2451296 C45.4930527,20.2451296 40.0195381,15.344608 40.0195381,9.67694348 C40.0195381,5.49520667 42.3399588,1.95870633 45.7064761,0 C40.2076534,0.797193688 35.9820516,4.99553347 35.8093622,10.106319 C33.2452605,11.8422828 29.7948543,12.9056086 26,12.9056086 C22.2051457,12.9056086 18.7547395,11.8422828 16.1906378,10.106319 Z" id="Delicious"></path>
|
||||
</g>
|
||||
<circle id="Redish" fill-opacity="0.2" fill="#FF4646" cx="17" cy="15" r="9"></circle>
|
||||
<circle id="Redish" fill-opacity="0.2" fill="#FF4646" cx="91" cy="15" r="9"></circle>
|
||||
</g>
|
||||
"""),
|
||||
Grimace("""
|
||||
<g id="Mouth/Grimace" transform="translate(2.000000, 56.000000)">
|
||||
<defs>
|
||||
<rect id="react-path-59742" x="24" y="9" width="60" height="22" rx="11"></rect>
|
||||
</defs>
|
||||
<rect id="Mouth" fill-opacity="0.599999964" fill="#000000" fill-rule="evenodd" x="22" y="7" width="64" height="26" rx="13"></rect>
|
||||
<mask id="react-mask-59743" fill="white">
|
||||
<use xlink:href="#react-path-59742"></use>
|
||||
</mask>
|
||||
<use id="Mouth" fill="#FFFFFF" fill-rule="evenodd" xlink:href="#react-path-59742"></use>
|
||||
<path d="M71,22 L62,22 L62,34 L58,34 L58,22 L49,22 L49,34 L45,34 L45,22 L36,22 L36,34 L32,34 L32,22 L24,22 L24,18 L32,18 L32,6 L36,6 L36,18 L45,18 L45,6 L49,6 L49,18 L58,18 L58,6 L62,6 L62,18 L71,18 L71,6 L75,6 L75,18 L83.8666667,18 L83.8666667,22 L75,22 L75,34 L71,34 L71,22 Z" id="Grimace-Teeth" fill="#E6E6E6" fill-rule="evenodd" mask="url(#react-mask-59743)"></path>
|
||||
</g>
|
||||
"""),
|
||||
ScreamOpen("""
|
||||
<g id="Mouth/Scream-Open" transform="translate(2.000000, 52.000000)">
|
||||
<defs>
|
||||
<path d="M34.0082051,15.1361102 C35.1280248,29.123916 38.2345159,40.9925405 53.9961505,40.9999965 C69.757785,41.0074525 72.9169073,29.0566179 73.9942614,15.0063928 C74.0809675,13.8756222 73.1738581,12.9999965 72.0369872,12.9999965 C65.3505138,12.9999965 62.6703194,14.9936002 53.9894323,14.9999965 C45.3085452,15.0063928 40.7567994,12.9999965 36.0924943,12.9999965 C34.9490269,12.9999965 33.8961688,13.7366502 34.0082051,15.1361102 Z" id="react-path-15062"></path>
|
||||
</defs>
|
||||
<mask id="react-mask-15063" fill="white">
|
||||
<use xlink:href="#react-path-15062" transform="translate(54.000000, 26.999998) scale(1, -1) translate(-54.000000, -26.999998) "></use>
|
||||
</mask>
|
||||
<use id="Mouth" fill-opacity="0.699999988" fill="#000000" fill-rule="evenodd" transform="translate(54.000000, 26.999998) scale(1, -1) translate(-54.000000, -26.999998) " xlink:href="#react-path-15062"></use>
|
||||
<rect id="Teeth" fill="#FFFFFF" fill-rule="evenodd" mask="url(#react-mask-15063)" x="39" y="2" width="31" height="16" rx="5"></rect>
|
||||
<g id="Tongue" stroke-width="1" fill-rule="evenodd" mask="url(#react-mask-15063)" fill="#FF4F6D">
|
||||
<g transform="translate(38.000000, 32.000000)" id="Say-ahhhh">
|
||||
<circle cx="11" cy="11" r="11"></circle>
|
||||
<circle cx="21" cy="11" r="11"></circle>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
"""),
|
||||
Sad("""
|
||||
<g
|
||||
id="Mouth/Sad"
|
||||
transform="translate(2.000000, 52.000000)"
|
||||
fill-opacity="0.699999988"
|
||||
fill="#000000">
|
||||
<path
|
||||
d="M40.0582943,16.6539438 C40.7076459,23.6831146 46.7016363,28.3768187 54,28.3768187 C61.3416045,28.3768187 67.3633339,23.627332 67.9526838,16.5287605 C67.9840218,16.1513016 67.0772329,15.8529531 66.6289111,16.077395 C61.0902255,18.8502083 56.8805885,20.2366149 54,20.2366149 C51.1558456,20.2366149 47.0072148,18.8804569 41.5541074,16.168141 C41.0473376,15.9160792 40.0197139,16.2363147 40.0582943,16.6539438 Z"
|
||||
id="Mouth"
|
||||
transform="translate(54.005357, 22.188409) scale(1, -1) translate(-54.005357, -22.188409) "
|
||||
/>
|
||||
</g>
|
||||
"""),
|
||||
Serious("""
|
||||
<g id="Mouth/Serious" transform="translate(2.000000, 52.000000)" fill="#000000" fill-opacity="0.699999988">
|
||||
<rect id="Why-so-serious?" x="42" y="18" width="24" height="6" rx="3"></rect>
|
||||
</g>
|
||||
"""),
|
||||
Smile("""
|
||||
<g id="Mouth/Smile" transform="translate(2.000000, 52.000000)">
|
||||
<defs>
|
||||
<path d="M35.117844,15.1280772 C36.1757121,24.6198025 44.2259873,32 54,32 C63.8042055,32 71.8740075,24.574136 72.8917593,15.0400546 C72.9736685,14.272746 72.1167429,13 71.042767,13 C56.1487536,13 44.7379213,13 37.0868244,13 C36.0066168,13 35.0120058,14.1784435 35.117844,15.1280772 Z" id="react-path-17111"></path>
|
||||
</defs>
|
||||
<mask id="react-mask-17112" fill="white">
|
||||
<use xlink:href="#react-path-17111"></use>
|
||||
</mask>
|
||||
<use id="Mouth" fill-opacity="0.699999988" fill="#000000" fill-rule="evenodd" xlink:href="#react-path-17111"></use>
|
||||
<rect id="Teeth" fill="#FFFFFF" fill-rule="evenodd" mask="url(#react-mask-17112)" x="39" y="2" width="31" height="16" rx="5"></rect>
|
||||
<g id="Tongue" stroke-width="1" fill-rule="evenodd" mask="url(#react-mask-17112)" fill="#FF4F6D">
|
||||
<g transform="translate(38.000000, 24.000000)">
|
||||
<circle cx="11" cy="11" r="11"></circle>
|
||||
<circle cx="21" cy="11" r="11"></circle>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
"""),
|
||||
Tongue("""
|
||||
<g id="Mouth/Tongue" transform="translate(2.000000, 52.000000)">
|
||||
<defs>
|
||||
<path d="M29,15.6086957 C30.410031,25.2313711 41.062182,33 54,33 C66.9681454,33 77.6461342,25.183301 79,14.7391304 C79.1012093,14.3397326 78.775269,13 76.826087,13 C56.838426,13 41.7395748,13 31.173913,13 C29.3833142,13 28.870211,14.2404669 29,15.6086957 Z" id="react-path-17809"></path>
|
||||
</defs>
|
||||
<mask id="react-mask-17810" fill="white">
|
||||
<use xlink:href="#react-path-17809"></use>
|
||||
</mask>
|
||||
<use id="Mouth" fill-opacity="0.699999988" fill="#000000" fill-rule="evenodd" xlink:href="#react-path-17809"></use>
|
||||
<rect id="Teeth" fill="#FFFFFF" fill-rule="evenodd" mask="url(#react-mask-17810)" x="39" y="2" width="31" height="16" rx="5"></rect>
|
||||
<path d="M65.9841079,23.7466656 C65.9945954,23.8296335 66,23.9141856 66,24 L66,33 C66,39.0751322 61.0751322,44 55,44 L54,44 C47.9248678,44 43,39.0751322 43,33 L43,24 L43,24 C43,23.9141856 43.0054046,23.8296335 43.0158921,23.7466656 C43.0053561,23.6651805 43,23.5829271 43,23.5 C43,21.5670034 45.9101491,20 49.5,20 C51.510438,20 53.3076958,20.4914717 54.5,21.2634601 C55.6923042,20.4914717 57.489562,20 59.5,20 C63.0898509,20 66,21.5670034 66,23.5 C66,23.5829271 65.9946439,23.6651805 65.9841079,23.7466656 Z" id="Tongue" fill="#FF4F6D" fill-rule="evenodd"></path>
|
||||
</g>
|
||||
"""),
|
||||
Twinkle("""
|
||||
<g id="Mouth/Twinkle" transform="translate(2.000000, 52.000000)" fill-opacity="0.599999964" fill-rule="nonzero" fill="#000000">
|
||||
<path d="M40,16 C40,21.371763 46.1581544,25 54,25 C61.8418456,25 68,21.371763 68,16 C68,14.8954305 67.050301,14 66,14 C64.7072748,14 64.1302316,14.9051755 64,16 C62.7575758,18.9378973 59.6832595,20.7163149 54,21 C48.3167405,20.7163149 45.2424242,18.9378973 44,16 C43.8697684,14.9051755 43.2927252,14 42,14 C40.949699,14 40,14.8954305 40,16 Z" id="Mouth"></path>
|
||||
</g>
|
||||
"""),
|
||||
Vomit("""
|
||||
<g id="Mouth/Vomit" transform="translate(2.000000, 52.000000)">
|
||||
<defs>
|
||||
<path d="M34.0082051,12.6020819 C35.1280248,23.0929366 38.2345159,31.9944054 53.9961505,31.9999974 C69.757785,32.0055894 72.9169073,23.0424631 73.9942614,12.5047938 C74.0809675,11.6567158 73.1738581,10.9999965 72.0369872,10.9999965 C65.3505138,10.9999965 62.6703194,12.4951994 53.9894323,12.4999966 C45.3085452,12.5047938 40.7567994,10.9999965 36.0924943,10.9999965 C34.9490269,10.9999965 33.8961688,11.5524868 34.0082051,12.6020819 Z" id="react-path-106864"></path>
|
||||
<path d="M59.9170416,36 L60,36 C60,39.3137085 62.6862915,42 66,42 C69.3137085,42 72,39.3137085 72,36 L72,35 L72,31 C72,27.6862915 69.3137085,25 66,25 L66,25 L42,25 L42,25 C38.6862915,25 36,27.6862915 36,31 L36,31 L36,35 L36,38 C36,41.3137085 38.6862915,44 42,44 C45.3137085,44 48,41.3137085 48,38 L48,36 L48.0829584,36 C48.5590365,33.1622867 51.0270037,31 54,31 C56.9729963,31 59.4409635,33.1622867 59.9170416,36 Z" id="react-path-106865"></path>
|
||||
<filter x="-1.4%" y="-2.6%" width="102.8%" height="105.3%" filterUnits="objectBoundingBox" id="react-filter-106867">
|
||||
<feOffset dx="0" dy="-1" in="SourceAlpha" result="shadowOffsetInner1"></feOffset>
|
||||
<feComposite in="shadowOffsetInner1" in2="SourceAlpha" operator="arithmetic" k2="-1" k3="1" result="shadowInnerInner1"></feComposite>
|
||||
<feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0" type="matrix" in="shadowInnerInner1"></feColorMatrix>
|
||||
</filter>
|
||||
</defs>
|
||||
<mask id="react-mask-106866" fill="white">
|
||||
<use xlink:href="#react-path-106864" transform="translate(54.000000, 21.499998) scale(1, -1) translate(-54.000000, -21.499998) "></use>
|
||||
</mask>
|
||||
<use id="Mouth" fill-opacity="0.699999988" fill="#000000" fill-rule="evenodd" transform="translate(54.000000, 21.499998) scale(1, -1) translate(-54.000000, -21.499998) " xlink:href="#react-path-106864"></use>
|
||||
<rect id="Teeth" fill="#FFFFFF" fill-rule="evenodd" mask="url(#react-mask-106866)" x="39" y="0" width="31" height="16" rx="5"></rect>
|
||||
<g id="Vomit-Stuff">
|
||||
<use fill="green" fill-rule="evenodd" xlink:href="#react-path-106865"></use>
|
||||
<use fill="green" fill-opacity="1" filter="url(#react-filter-106867)" xlink:href="#react-path-106865"></use>
|
||||
</g>
|
||||
</g>
|
||||
""");
|
||||
|
||||
final String svg;
|
||||
|
||||
const Mouths(this.svg);
|
||||
|
||||
String get label => name;
|
||||
String get id => "Mouth/$name";
|
||||
String get value => svg;
|
||||
}
|
||||
18
avatar_maker/lib/src/core/enums/property_items/noses.dart
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import "package:avatar_maker/src/core/models/property_item.dart";
|
||||
|
||||
/// List of all the noses displayed by default.
|
||||
enum Noses implements PropertyItem {
|
||||
Default("""
|
||||
<g id="Nose/Default" transform="translate(28.000000, 40.000000)" opacity="0.16">
|
||||
<path d="M16,8 C16,12.418278 21.372583,16 28,16 L28,16 C34.627417,16 40,12.418278 40,8" id="Nose"></path>
|
||||
</g>
|
||||
""");
|
||||
|
||||
final String svg;
|
||||
|
||||
const Noses(this.svg);
|
||||
|
||||
String get label => name;
|
||||
String get id => "Nose/${name}";
|
||||
String get value => svg;
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import "package:avatar_maker/src/core/models/property_item.dart";
|
||||
|
||||
/// List of all the outfits displayed by default.
|
||||
enum OutfitColors implements PropertyItem {
|
||||
Black("#262E33"),
|
||||
LightBlue("#65C9FF"),
|
||||
Blue("#5199E4"),
|
||||
DarkBlue("#25557C"),
|
||||
LightGray("#E6E6E6"),
|
||||
Gray("#929598"),
|
||||
Heather("#3C4F5C"),
|
||||
PastelBlue("#B1E2FF"),
|
||||
PastelGreen("#A7FFC4"),
|
||||
PastelOrange("#FFDEB5"),
|
||||
PastelRed("#FFAFB9"),
|
||||
PastelYellow("#FFFFB1"),
|
||||
Pink("#FF488E"),
|
||||
Red("#FF5C5C"),
|
||||
White("#FFFFFF"),
|
||||
Green("#1B5E20"),
|
||||
Purple("#8E24AA"),
|
||||
Fuchsia("#D81B60"),
|
||||
Orange("#E64A19"),
|
||||
Lemon("#CDDC39");
|
||||
|
||||
final String hexCode;
|
||||
|
||||
const OutfitColors(this.hexCode);
|
||||
|
||||
String get label => this.name;
|
||||
String get id => "OutfitColor/$name";
|
||||
String get value => this.hexCode;
|
||||
}
|
||||
177
avatar_maker/lib/src/core/enums/property_items/outfit_types.dart
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
import "package:avatar_maker/src/core/enums/placeholders.dart";
|
||||
import "package:avatar_maker/src/core/models/property_item.dart";
|
||||
|
||||
/// List of all the outfit types displayed by default.
|
||||
enum OutfitTypes implements PropertyItem {
|
||||
BlazerTShirt("""
|
||||
<g id=" OutfitTypes/BlazerTShirt" transform="translate(0.000000, 170.000000)">
|
||||
<defs>
|
||||
<path d="M133.960472,0.294916112 C170.936473,3.32499816 200,34.2942856 200,72.0517235 L200,81 L0,81 L0,72.0517235 C1.22536245e-14,33.9525631 29.591985,2.76498122 67.0454063,0.219526408 C67.0152598,0.593114549 67,0.969227185 67,1.34762511 C67,13.2107177 81.9984609,22.8276544 100.5,22.8276544 C119.001539,22.8276544 134,13.2107177 134,1.34762511 C134,0.994669088 133.986723,0.64370138 133.960472,0.294916112 Z" id="react-path-34666"></path>
|
||||
</defs>
|
||||
<g id="Shirt" transform="translate(32.000000, 29.000000)">
|
||||
<mask id="react-mask-34667" fill="white">
|
||||
<use xlink:href="#react-path-34666"></use>
|
||||
</mask>
|
||||
<use id="Clothes" fill="#E6E6E6" xlink:href="#react-path-34666"></use>
|
||||
<g id="$TO_REPLACE_WITH_OUTFIT_COLOR_NAME" mask="url(#react-mask-34667)" fill="$TO_REPLACE_WITH_OUTFIT_COLOR">
|
||||
<g transform="translate(-32.000000, -29.000000)" id="🖍Color">
|
||||
<rect x="0" y="0" width="264" height="110"></rect>
|
||||
</g>
|
||||
</g>
|
||||
<g id="Shadowy" opacity="0.599999964" mask="url(#react-mask-34667)" fill-opacity="0.16" fill="#000000">
|
||||
<g transform="translate(60.000000, -25.000000)" id="Hola">
|
||||
<ellipse cx="40.5" cy="27.8476251" rx="39.6351047" ry="26.9138272"></ellipse>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g id="Blazer" transform="translate(32.000000, 28.000000)">
|
||||
<path d="M68.784807,1.12222847 C30.512317,2.80409739 -1.89486556e-14,34.3646437 -1.42108547e-14,73.0517235 L0,73.0517235 L0,82 L69.3616767,82 C65.9607412,69.9199941 64,55.7087296 64,40.5 C64,26.1729736 65.7399891,12.7311115 68.784807,1.12222847 Z M131.638323,82 L200,82 L200,73.0517235 C200,34.7067641 170.024954,3.36285166 132.228719,1.17384225 C135.265163,12.7709464 137,26.1942016 137,40.5 C137,55.7087296 135.039259,69.9199941 131.638323,82 Z" id="Saco" fill="#3A4C5A"></path>
|
||||
<path d="M149,58 L158.555853,50.83311 L158.555853,50.83311 C159.998897,49.7508275 161.987779,49.7682725 163.411616,50.8757011 L170,56 L149,58 Z" id="Pocket-hanky" fill="#E6E6E6"></path>
|
||||
<path d="M69,1.13686838e-13 C65,19.3333333 66.6666667,46.6666667 74,82 L58,82 L44,46 L50,37 L44,31 L63,1 C65.027659,0.369238637 67.027659,0.0359053037 69,1.13686838e-13 Z" id="Wing" fill="#2F4351"></path>
|
||||
<path d="M151,1.13686838e-13 C147,19.3333333 148.666667,46.6666667 156,82 L140,82 L126,46 L132,37 L126,31 L145,1 C147.027659,0.369238637 149.027659,0.0359053037 151,1.13686838e-13 Z" id="Wing" fill="#2F4351" transform="translate(141.000000, 41.000000) scale(-1, 1) translate(-141.000000, -41.000000) "></path>
|
||||
</g>
|
||||
</g>
|
||||
"""),
|
||||
BlazerSweater("""
|
||||
<g id=" OutfitTypes/BlazerSweater" transform="translate(0.000000, 170.000000)">
|
||||
<defs>
|
||||
<path d="M105.192402,29.0517235 L104,29.0517235 L104,29.0517235 C64.235498,29.0517235 32,61.2872215 32,101.051724 L32,110 L232,110 L232,101.051724 C232,61.2872215 199.764502,29.0517235 160,29.0517235 L160,29.0517235 L158.807598,29.0517235 C158.934638,30.0353144 159,31.0364513 159,32.0517235 C159,45.8588423 146.911688,57.0517235 132,57.0517235 C117.088312,57.0517235 105,45.8588423 105,32.0517235 C105,31.0364513 105.065362,30.0353144 105.192402,29.0517235 Z" id="react-path-34905"></path>
|
||||
</defs>
|
||||
<mask id="react-mask-34906" fill="white">
|
||||
<use xlink:href="#react-path-34905"></use>
|
||||
</mask>
|
||||
<use id="Clothes" fill="#E6E6E6" fill-rule="evenodd" xlink:href="#react-path-34905"></use>
|
||||
<g id="$TO_REPLACE_WITH_OUTFIT_COLOR_NAME" mask="url(#react-mask-34906)" fill-rule="evenodd" fill="$TO_REPLACE_WITH_OUTFIT_COLOR">
|
||||
<rect id="🖍Color" x="0" y="0" width="264" height="110"></rect>
|
||||
</g>
|
||||
<g id="Blazer" stroke-width="1" fill-rule="evenodd" transform="translate(32.000000, 28.000000)">
|
||||
<path d="M68.784807,1.12222847 C30.512317,2.80409739 1.24427139e-14,34.3646437 0,73.0517235 L0,82 L69.3616767,82 C65.9607412,69.9199941 64,55.7087296 64,40.5 C64,26.1729736 65.7399891,12.7311115 68.784807,1.12222847 Z M131.638323,82 L200,82 L200,73.0517235 C200,34.7067641 170.024954,3.36285166 132.228719,1.17384225 C135.265163,12.7709464 137,26.1942016 137,40.5 C137,55.7087296 135.039259,69.9199941 131.638323,82 Z" id="Saco" fill="#3A4C5A"></path>
|
||||
<path d="M149,58 L158.555853,50.83311 L158.555853,50.83311 C159.998897,49.7508275 161.987779,49.7682725 163.411616,50.8757011 L170,56 L149,58 Z" id="Pocket-hanky" fill="#E6E6E6"></path>
|
||||
<path d="M69,1.13686838e-13 C65,19.3333333 66.6666667,46.6666667 74,82 L58,82 L44,46 L50,37 L44,31 L63,1 C65.027659,0.369238637 67.027659,0.0359053037 69,1.13686838e-13 Z" id="Wing" fill="#2F4351"></path>
|
||||
<path d="M151,1.13686838e-13 C147,19.3333333 148.666667,46.6666667 156,82 L140,82 L126,46 L132,37 L126,31 L145,1 C147.027659,0.369238637 149.027659,0.0359053037 151,1.13686838e-13 Z" id="Wing" fill="#2F4351" transform="translate(141.000000, 41.000000) scale(-1, 1) translate(-141.000000, -41.000000) "></path>
|
||||
</g>
|
||||
<path d="M156,21.5390062 C162.772319,26.1359565 167,32.6563196 167,39.8878801 C167,47.2887711 162.572015,53.9447688 155.520105,58.5564942 L149.57933,53.8764929 L145,54.207887 L146,51.0567821 L145.922229,50.995516 C152.022491,47.8530505 156,42.7003578 156,36.8768102 L156,21.5390062 Z M108,21.5390062 C101.227681,26.1359565 97,32.6563196 97,39.8878801 C97,47.2887711 101.427985,53.9447688 108.479895,58.5564942 L114.42067,53.8764929 L119,54.207887 L118,51.0567821 L118.077771,50.995516 C111.977509,47.8530505 108,42.7003578 108,36.8768102 L108,21.5390062 Z" id="Collar" fill="#F2F2F2" fill-rule="evenodd"></path>
|
||||
</g>
|
||||
"""),
|
||||
CollarSweater("""
|
||||
<g id=" OutfitTypes/CollarSweater" transform="translate(0.000000, 170.000000)">
|
||||
<defs>
|
||||
<path d="M105.192402,29.0517235 L104,29.0517235 L104,29.0517235 C64.235498,29.0517235 32,61.2872215 32,101.051724 L32,110 L232,110 L232,101.051724 C232,61.2872215 199.764502,29.0517235 160,29.0517235 L160,29.0517235 L158.807598,29.0517235 C158.934638,30.0353144 159,31.0364513 159,32.0517235 C159,45.8588423 146.911688,57.0517235 132,57.0517235 C117.088312,57.0517235 105,45.8588423 105,32.0517235 C105,31.0364513 105.065362,30.0353144 105.192402,29.0517235 Z" id="react-path-35116"></path>
|
||||
</defs>
|
||||
<mask id="react-mask-35117" fill="white">
|
||||
<use xlink:href="#react-path-35116"></use>
|
||||
</mask>
|
||||
<use id="Clothes" fill="#E6E6E6" fill-rule="evenodd" xlink:href="#react-path-35116"></use>
|
||||
<g id="$TO_REPLACE_WITH_OUTFIT_COLOR_NAME" mask="url(#react-mask-35117)" fill-rule="evenodd" fill="$TO_REPLACE_WITH_OUTFIT_COLOR">
|
||||
<rect id="🖍Color" x="0" y="0" width="264" height="110"></rect>
|
||||
</g>
|
||||
<path d="M156,22.2794906 C162.181647,26.8351858 166,33.1057265 166,40.027915 C166,47.2334941 161.862605,53.7329769 155.228997,58.3271669 L149.57933,53.8764929 L145,54.207887 L146,51.0567821 L145.922229,50.995516 C152.022491,47.8530505 156,42.7003578 156,36.8768102 L156,22.2794906 Z M108,21.5714994 C101.232748,26.1740081 97,32.7397769 97,40.027915 C97,47.4261549 101.361602,54.080035 108.308428,58.6915723 L114.42067,53.8764929 L119,54.207887 L118,51.0567821 L118.077771,50.995516 C111.977509,47.8530505 108,42.7003578 108,36.8768102 L108,21.5714994 Z" id="Collar" fill="#F2F2F2" fill-rule="evenodd"></path>
|
||||
</g>
|
||||
"""),
|
||||
GraphicShirt("""
|
||||
<g id=" OutfitTypes/GraphicShirt" transform="translate(0.000000, 170.000000)">
|
||||
<defs>
|
||||
<path d="M165.624032,29.2681342 C202.760022,32.1373245 232,63.1798426 232,101.051724 L232,110 L32,110 L32,101.051724 C32,62.8348009 61.7752018,31.5722494 99.3929298,29.1967444 C99.1342224,30.2735458 99,31.3767131 99,32.5 C99,44.3741221 113.998461,54 132.5,54 C151.001539,54 166,44.3741221 166,32.5 C166,31.4015235 165.871641,30.3222877 165.624025,29.2681336 Z" id="react-path-35920"></path>
|
||||
</defs>
|
||||
<mask id="react-mask-35921" fill="white">
|
||||
<use xlink:href="#react-path-35920"></use>
|
||||
</mask>
|
||||
<use id="Clothes" fill="#E6E6E6" fill-rule="evenodd" xlink:href="#react-path-35920"></use>
|
||||
<g id="$TO_REPLACE_WITH_OUTFIT_COLOR_NAME" mask="url(#react-mask-35921)" fill-rule="evenodd" fill="$TO_REPLACE_WITH_OUTFIT_COLOR">
|
||||
<rect id="🖍Color" x="0" y="0" width="264" height="110"></rect>
|
||||
</g>
|
||||
<g id=" OutfitTypes/Graphic/Skull" mask="url(#react-mask-35921)" fill-rule="evenodd" fill="#FFFFFF">
|
||||
<g transform="translate(77.000000, 58.000000)" id="Fill-49">
|
||||
<path d="M65.2820354,19.9288113 C64.9841617,22.7059411 59.588846,24.2025715 57.6102394,22.3766824 C56.6984983,21.5350479 56.6825881,19.8029182 56.4815012,18.6751632 C56.1014249,16.5420247 55.8256486,14.4172496 55.7306295,12.2519776 C55.6727342,10.9274596 55.3253621,9.86749314 56.7745135,9.67029008 C57.65797,9.55055964 58.5675014,10.137767 59.2896464,10.6026028 C61.736719,12.1758255 65.6201265,16.7414286 65.2820354,19.9288113 M52.8813831,14.0756657 C53.1659984,16.901216 54.2014853,21.8145656 51.9457767,24.1810024 C49.9296045,26.2960933 45.7863308,24.19905 45.1631825,21.7084809 C44.3897714,18.6188195 47.4383369,14.9274245 49.307782,12.8387447 C49.881874,12.1969544 51.151594,10.4256483 52.1442119,11.018578 C52.526056,11.2461539 52.8367463,13.6301981 52.8813831,14.0756657 M54.3212536,25.1062722 C54.9678252,23.5832306 61.2342228,28.1246236 58.2744891,30.2850536 C57.7918806,30.6376421 54.1148633,31.7513112 53.4099544,31.2274906 C51.9250051,30.1235056 53.8408548,26.2630794 54.3212536,25.1062722 M73.3250687,17.5267194 C72.8817937,2.05112066 53.065234,-2.31331777 42.4756895,6.50447654 C38.426551,9.87585667 36.113389,14.0039155 36.0073212,19.2826191 C35.9171635,23.7544627 36.6256081,27.9718792 40.0409914,31.0465744 C41.5219631,32.379896 42.5004386,33.1955596 43.2862243,35.0170469 C44.1095756,36.9234899 44.4852324,39.3524331 46.0280771,40.8495037 C46.8788292,41.6752915 48.1176128,42.3417322 49.2940816,41.8091079 C51.455655,40.8301355 50.7644465,37.8320326 51.4194152,36.1606486 C53.4559171,41.1294616 58.6302582,42.7141291 59.5694002,36.4097935 C60.6000257,38.2286397 63.2945899,40.610483 65.268335,38.6195243 C66.0806376,37.8003393 66.2030575,36.4705391 66.3409457,35.3929652 C66.5857855,33.4807998 66.1601884,32.7294032 67.6955199,31.4180909 C71.7349355,27.9683578 73.4691441,22.7464381 73.3250687,17.5267194"></path>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
"""),
|
||||
Hoodie("""
|
||||
<g id=" OutfitTypes/Hoodie" transform="translate(0.000000, 170.000000)">
|
||||
<defs>
|
||||
<path d="M108,13.0708856 C90.0813006,15.075938 76.2798424,20.5518341 76.004203,34.6449676 C50.1464329,45.5680933 32,71.1646257 32,100.999485 L32,100.999485 L32,110 L232,110 L232,100.999485 C232,71.1646257 213.853567,45.5680933 187.995797,34.6449832 C187.720158,20.5518341 173.918699,15.075938 156,13.0708856 L156,32 L156,32 C156,45.254834 145.254834,56 132,56 L132,56 C118.745166,56 108,45.254834 108,32 L108,13.0708856 Z" id="react-path-35937"></path>
|
||||
</defs>
|
||||
<mask id="react-mask-35938" fill="white">
|
||||
<use xlink:href="#react-path-35937"></use>
|
||||
</mask>
|
||||
<use id="Hoodie" fill="#B7C1DB" fill-rule="evenodd" xlink:href="#react-path-35937"></use>
|
||||
<g id="$TO_REPLACE_WITH_OUTFIT_COLOR_NAME" mask="url(#react-mask-35938)" fill-rule="evenodd" fill="$TO_REPLACE_WITH_OUTFIT_COLOR">
|
||||
<rect id="🖍Color" x="0" y="0" width="264" height="110"></rect>
|
||||
</g>
|
||||
<path d="M102,61.7390531 L102,110 L95,110 L95,58.1502625 C97.2037542,59.4600576 99.5467694,60.6607878 102,61.7390531 Z M169,58.1502625 L169,98.5 C169,100.432997 167.432997,102 165.5,102 C163.567003,102 162,100.432997 162,98.5 L162,61.7390531 C164.453231,60.6607878 166.796246,59.4600576 169,58.1502625 Z" id="Straps" fill="#F4F4F4" fill-rule="evenodd" mask="url(#react-mask-35938)"></path>
|
||||
<path d="M90.9601329,12.7243537 C75.9093095,15.5711782 65.5,21.2428847 65.5,32.3076923 C65.5,52.0200095 98.5376807,68 132,68 C165.462319,68 198.5,52.0200095 198.5,32.3076923 C198.5,21.2428847 188.09069,15.5711782 173.039867,12.7243537 C182.124921,16.0744598 188,21.7060546 188,31.0769231 C188,51.4689754 160.178795,68 132,68 C103.821205,68 76,51.4689754 76,31.0769231 C76,21.7060546 81.8750795,16.0744598 90.9601329,12.7243537 Z" id="Shadow" fill-opacity="0.16" fill="#000000" fill-rule="evenodd" mask="url(#react-mask-35938)"></path>
|
||||
</g>
|
||||
"""),
|
||||
Overall("""
|
||||
<g id=" OutfitTypes/Overall" transform="translate(0.000000, 170.000000)">
|
||||
<defs>
|
||||
<path d="M94,29.6883435 L94,74 L170,74 L170,29.6883435 C179.362956,30.9893126 188.149952,34.0907916 196.00002,38.6318143 L196,110 L187,110 L77,110 L68,110 L68,38.6318027 C75.8500482,34.0907916 84.6370437,30.9893126 94,29.6883435 Z" id="react-path-35771"></path>
|
||||
</defs>
|
||||
<mask id="react-mask-35772" fill="white">
|
||||
<use xlink:href="#react-path-35771"></use>
|
||||
</mask>
|
||||
<use id="Overall" fill="#B7C1DB" fill-rule="evenodd" xlink:href="#react-path-35771"></use>
|
||||
<g id="$TO_REPLACE_WITH_OUTFIT_COLOR_NAME" mask="url(#react-mask-35772)" fill-rule="evenodd" fill="$TO_REPLACE_WITH_OUTFIT_COLOR">
|
||||
<rect id="🖍Color" x="0" y="0" width="264" height="110"></rect>
|
||||
</g>
|
||||
<circle id="Button" fill="#F4F4F4" fill-rule="evenodd" cx="81" cy="83" r="5"></circle>
|
||||
<circle id="Button" fill="#F4F4F4" fill-rule="evenodd" cx="183" cy="83" r="5"></circle>
|
||||
</g>
|
||||
"""),
|
||||
ShirtCrewNeck("""
|
||||
<g id=" OutfitTypes/ShirtCrewNeck" transform="translate(0.000000, 170.000000)">
|
||||
<defs>
|
||||
<path d="M165.960472,29.2949161 C202.936473,32.3249982 232,63.2942856 232,101.051724 L232,110 L32,110 L32,101.051724 C32,62.9525631 61.591985,31.7649812 99.0454063,29.2195264 C99.0152598,29.5931145 99,29.9692272 99,30.3476251 C99,42.2107177 113.998461,51.8276544 132.5,51.8276544 C151.001539,51.8276544 166,42.2107177 166,30.3476251 C166,29.9946691 165.986723,29.6437014 165.960472,29.2949161 Z" id="react-path-36269"></path>
|
||||
</defs>
|
||||
<mask id="react-mask-36270" fill="white">
|
||||
<use xlink:href="#react-path-36269"></use>
|
||||
</mask>
|
||||
<use id="Clothes" fill="#E6E6E6" fill-rule="evenodd" xlink:href="#react-path-36269"></use>
|
||||
<g id="$TO_REPLACE_WITH_OUTFIT_COLOR_NAME" mask="url(#react-mask-36270)" fill-rule="evenodd" fill="$TO_REPLACE_WITH_OUTFIT_COLOR">
|
||||
<rect id="🖍Color" x="0" y="0" width="264" height="110"></rect>
|
||||
</g>
|
||||
<g id="Shadowy" opacity="0.599999964" stroke-width="1" fill-rule="evenodd" mask="url(#react-mask-36270)" fill-opacity="0.16" fill="#000000">
|
||||
<g transform="translate(92.000000, 4.000000)" id="Hola">
|
||||
<ellipse cx="40.5" cy="27.8476251" rx="39.6351047" ry="26.9138272"></ellipse>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
"""),
|
||||
ShirtScoopNeck("""
|
||||
<g id=" OutfitTypes/ShirtScoopNeck" transform="translate(0.000000, 170.000000)">
|
||||
<defs>
|
||||
<path d="M181.544255,32.3304167 C210.784209,41.4878872 232,68.7921987 232,101.051724 L232,110 L32,110 L32,101.051724 C32,68.3969699 53.7388273,40.8195914 83.5340368,32.0020332 C83.182234,33.4201865 83,34.8712315 83,36.3476251 C83,52.6289957 105.161905,65.8276544 132.5,65.8276544 C159.838095,65.8276544 182,52.6289957 182,36.3476251 C182,34.9849859 181.844766,33.6439396 181.544255,32.3304167 Z" id="react-path-36388"></path>
|
||||
</defs>
|
||||
<mask id="react-mask-36389" fill="white">
|
||||
<use xlink:href="#react-path-36388"></use>
|
||||
</mask>
|
||||
<use id="Clothes" fill="#E6E6E6" fill-rule="evenodd" xlink:href="#react-path-36388"></use>
|
||||
<g id="$TO_REPLACE_WITH_OUTFIT_COLOR_NAME" mask="url(#react-mask-36389)" fill-rule="evenodd" fill="$TO_REPLACE_WITH_OUTFIT_COLOR">
|
||||
<rect id="🖍Color" x="0" y="0" width="264" height="110"></rect>
|
||||
</g>
|
||||
</g>
|
||||
"""),
|
||||
ShirtVNeck("""
|
||||
<g id=" OutfitTypes/ShirtVNeck" transform="translate(0.000000, 170.000000)">
|
||||
<defs>
|
||||
<path d="M171.319631,29.9364358 C205.706337,35.3665707 232,65.13854 232,101.051724 L232,110 L32,110 L32,101.051724 C32,65.1380521 58.2943778,35.3657617 92.6817711,29.9362145 C93.5835973,35.0053598 96.116393,39.8238432 100.236125,43.5389794 L100.236125,43.5389794 L129.321203,69.7676333 C130.843316,71.1402598 133.156684,71.1402598 134.678797,69.7676333 L134.678797,69.7676333 L163.763875,43.5389794 C164.189462,43.1551884 164.601167,42.7562772 164.998197,42.3430127 C168.414164,38.7873666 170.517305,34.4520434 171.319628,29.9364354 Z" id="react-path-36091"></path>
|
||||
</defs>
|
||||
<mask id="react-mask-36092" fill="white">
|
||||
<use xlink:href="#react-path-36091"></use>
|
||||
</mask>
|
||||
<use id="Clothes" fill="#E6E6E6" fill-rule="evenodd" xlink:href="#react-path-36091"></use>
|
||||
<g id="$TO_REPLACE_WITH_OUTFIT_COLOR_NAME" mask="url(#react-mask-36092)" fill-rule="evenodd" fill="$TO_REPLACE_WITH_OUTFIT_COLOR">
|
||||
<rect id="🖍Color" x="0" y="0" width="264" height="110"></rect>
|
||||
</g>
|
||||
</g>
|
||||
""");
|
||||
|
||||
final String svg;
|
||||
|
||||
const OutfitTypes(this.svg);
|
||||
|
||||
String get label => name;
|
||||
String get id => " OutfitTypes/$name";
|
||||
String get value => svg;
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
export 'accessories.dart';
|
||||
export 'background_style.dart';
|
||||
export 'eyebrows.dart';
|
||||
export 'eyes.dart';
|
||||
export 'facial_hair_colors.dart';
|
||||
export 'facial_hair_types.dart';
|
||||
export 'hair_colors.dart';
|
||||
export 'hair_styles.dart';
|
||||
export 'mouths.dart';
|
||||
export 'noses.dart';
|
||||
export 'outfit_colors.dart';
|
||||
export 'outfit_types.dart';
|
||||
export 'skin_colors.dart';
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
import "package:avatar_maker/src/core/models/property_item.dart";
|
||||
|
||||
/// List of all the accessories skin colors by default.
|
||||
enum SkinColors implements PropertyItem {
|
||||
Tanned("""
|
||||
<g id="SkinColor/Tanned" mask="url(#mask-6)" fill="#FD9841">
|
||||
<g transform="translate(0.000000, 0.000000)" id="Color">
|
||||
<rect x="0" y="0" width="264" height="280" />
|
||||
</g>
|
||||
</g>
|
||||
"""),
|
||||
Yellow("""
|
||||
<g id="SkinColor/Yellow" mask="url(#mask-6)" fill="#F8D25C">
|
||||
<g transform="translate(0.000000, 0.000000)" id="Color">
|
||||
<rect x="0" y="0" width="264" height="280" />
|
||||
</g>
|
||||
</g>
|
||||
"""),
|
||||
White("""
|
||||
<g id="SkinColor/White" mask="url(#mask-6)" fill="#FFDBB4">
|
||||
<g transform="translate(0.000000, 0.000000)" id="Color">
|
||||
<rect x="0" y="0" width="264" height="280" />
|
||||
</g>
|
||||
</g> """),
|
||||
Peach("""
|
||||
<g id="SkinColor/Pale" mask="url(#mask-6)" fill="#EDB98A">
|
||||
<g transform="translate(0.000000, 0.000000)" id="Color">
|
||||
<rect x="0" y="0" width="264" height="280" />
|
||||
</g>
|
||||
</g>
|
||||
"""),
|
||||
Brown("""
|
||||
<g id="SkinColor/Brown" mask="url(#mask-6)" fill="#D08B5B">
|
||||
<g transform="translate(0.000000, 0.000000)" id="Color">
|
||||
<rect x="0" y="0" width="264" height="280" />
|
||||
</g>
|
||||
</g>
|
||||
"""),
|
||||
DarkBrown("""
|
||||
<g id="SkinColor/DarkBrown" mask="url(#mask-6)" fill="#AE5D29">
|
||||
<g transform="translate(0.000000, 0.000000)" id="Color">
|
||||
<rect x="0" y="0" width="264" height="280" />
|
||||
</g>
|
||||
</g>
|
||||
"""),
|
||||
Black("""
|
||||
<g id="SkinColor/Black" mask="url(#mask-6)" fill="#614335">
|
||||
<g transform="translate(0.000000, 0.000000)" id="Color">
|
||||
<rect x="0" y="0" width="264" height="280" />
|
||||
</g>
|
||||
</g>
|
||||
""");
|
||||
|
||||
final String svg;
|
||||
|
||||
const SkinColors(this.svg);
|
||||
|
||||
String get label => this.name;
|
||||
|
||||
String get id => "SkinColor/$name";
|
||||
|
||||
String get value => this.svg;
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import "package:avatar_maker/src/core/enums/property_category_ids.dart";
|
||||
import "package:avatar_maker/src/core/models/property_item.dart";
|
||||
|
||||
/// Class to use by users to customize a category.
|
||||
class CustomizedPropertyCategory {
|
||||
/// Id of the property to customize. It can't be a new id.
|
||||
final PropertyCategoryIds id;
|
||||
|
||||
/// Name of the category to display. If a name is override by the user, the
|
||||
/// localization must be managed by the user for this category.
|
||||
final String? name;
|
||||
|
||||
/// Path to the svg icon file to use for the category.
|
||||
final String? iconFile;
|
||||
|
||||
/// List of properties to set for the category.
|
||||
final List<PropertyItem>? properties;
|
||||
|
||||
/// Boolean to know if a category must be displayed or not. Default = true
|
||||
final bool toDisplay;
|
||||
|
||||
/// Default value to use for this category. If the category is not displayed
|
||||
/// (so can't be updated), the default value will be the one used for all
|
||||
/// the users.
|
||||
final PropertyItem? defaultValue;
|
||||
|
||||
const CustomizedPropertyCategory({
|
||||
required this.id,
|
||||
this.name,
|
||||
this.iconFile,
|
||||
this.properties = null,
|
||||
this.toDisplay = true,
|
||||
this.defaultValue,
|
||||
});
|
||||
}
|
||||
34
avatar_maker/lib/src/core/models/property_category.dart
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import "package:avatar_maker/l10n/app_localizations.dart";
|
||||
import "package:avatar_maker/src/core/enums/property_category_ids.dart";
|
||||
import "package:avatar_maker/src/core/models/property_item.dart";
|
||||
|
||||
/// Represents a property category managed by the library.
|
||||
class PropertyCategory {
|
||||
/// Id of the property category.
|
||||
final PropertyCategoryIds id;
|
||||
|
||||
/// Function to define the right localized name to use.
|
||||
final String Function(AppLocalizations l10n) getL10nName;
|
||||
|
||||
/// Path to the SVG icon file to use.
|
||||
final String iconFile;
|
||||
|
||||
/// List of properties available for this category.
|
||||
final List<PropertyItem> properties;
|
||||
|
||||
/// Boolean to know if the category can be updated (if displayed) in the
|
||||
/// customizer.
|
||||
final bool toDisplay;
|
||||
|
||||
/// Default value for the property category.
|
||||
final PropertyItem defaultValue;
|
||||
|
||||
const PropertyCategory({
|
||||
required this.id,
|
||||
required this.getL10nName,
|
||||
required this.iconFile,
|
||||
required this.properties,
|
||||
required this.toDisplay,
|
||||
required this.defaultValue,
|
||||
});
|
||||
}
|
||||
6
avatar_maker/lib/src/core/models/property_item.dart
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
/// Represents an item of a category.
|
||||
class PropertyItem {
|
||||
String get label => "";
|
||||
String get id => "";
|
||||
String get value => "";
|
||||
}
|
||||
175
avatar_maker/lib/src/core/models/theme_data.dart
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
import "package:flutter/material.dart";
|
||||
|
||||
/// Defines the configuration of the overall visual [theme] for a [AvatarMakerCustomizer]
|
||||
/// and the widgets within it.
|
||||
///
|
||||
/// The [AvatarMakerCustomizer]'s `theme` property can be used to configure the appearance
|
||||
/// of the entire widget.
|
||||
///
|
||||
/// You can set the attributes of this class to make the customizer look more in
|
||||
/// line with your app's own theme and style.
|
||||
///
|
||||
/// See more:
|
||||
/// * [AvatarMakerThemeData.defaultTheme] which is applied by default to the widgets.
|
||||
class AvatarMakerThemeData {
|
||||
/// Customize the style of the attributes' titles at the top
|
||||
/// of the [AvatarMakerCustomizer]
|
||||
final TextStyle labelTextStyle;
|
||||
|
||||
/// Background color of the top and bottom rows of the [AvatarMakerCustomizer]
|
||||
final Color primaryBgColor;
|
||||
|
||||
/// Background color of the grid area of the [AvatarMakerCustomizer]
|
||||
final Color secondaryBgColor;
|
||||
|
||||
/// Box decoration style of a selected tile in a grid of [AvatarMakerCustomizer]
|
||||
///
|
||||
/// Defaults to a box with green borders.
|
||||
final Decoration selectedTileDecoration;
|
||||
|
||||
/// Box decoration style of an unselected tile in a grid of
|
||||
/// [AvatarMakerCustomizer]
|
||||
///
|
||||
/// Defaults to [null].
|
||||
final Decoration? unselectedTileDecoration;
|
||||
|
||||
/// Customize the color of the default
|
||||
/// save button in [AvatarMakerSaveButton]
|
||||
final Color iconColor;
|
||||
|
||||
/// Color of a selected icon in the bottom row of [AvatarMakerCustomizer]
|
||||
final Color selectedIconColor;
|
||||
|
||||
/// Color of an unselected icon in the bottom row of [AvatarMakerCustomizer]
|
||||
final Color unselectedIconColor;
|
||||
|
||||
/// Box decoration style of the [AvatarMakerCustomizer]
|
||||
final Decoration boxDecoration;
|
||||
|
||||
/// Define the scroll behaviour of all scrollable elements inside
|
||||
/// the [AvatarMakerCustomizer]
|
||||
final ScrollPhysics scrollPhysics;
|
||||
|
||||
/// Padding inside each tile in the grids of the [AvatarMakerCustomizer]
|
||||
final EdgeInsetsGeometry tilePadding;
|
||||
|
||||
/// Margin outside each tile in the grids of the [AvatarMakerCustomizer]
|
||||
final EdgeInsetsGeometry tileMargin;
|
||||
|
||||
/// Number of items per row in the customizer.
|
||||
final int gridCrossAxisCount;
|
||||
|
||||
/// Height factor of the screen to apply to the customizer container.
|
||||
final double heightFactor;
|
||||
|
||||
/// Width factor of the screen to apply to the customizer container.
|
||||
final double widthFactor;
|
||||
|
||||
/// Creates a visual [theme] for the [AvatarMakerCustomizer]
|
||||
/// and the widgets within it.
|
||||
///
|
||||
/// You can set the attributes of this class to make the customizer look more in
|
||||
/// line with your app's own theme and style.
|
||||
///
|
||||
/// See more:
|
||||
/// * [AvatarMakerThemeData.defaultTheme] which is applied by default to the widgets.
|
||||
AvatarMakerThemeData({
|
||||
TextStyle? labelTextStyle,
|
||||
Color? primaryBgColor,
|
||||
Color? secondaryBgColor,
|
||||
Decoration? selectedTileDecoration,
|
||||
Decoration? unselectedTileDecoration,
|
||||
Color? iconColor,
|
||||
Color? selectedIconColor,
|
||||
Color? unselectedIconColor,
|
||||
Decoration? boxDecoration,
|
||||
ScrollPhysics? scrollPhysics,
|
||||
EdgeInsetsGeometry? tilePadding,
|
||||
EdgeInsetsGeometry? tileMargin,
|
||||
int? nbrTilesRow,
|
||||
double? heightFactor,
|
||||
double? widthFactor,
|
||||
}) : this.primaryBgColor = primaryBgColor ?? defaultTheme.primaryBgColor,
|
||||
this.secondaryBgColor =
|
||||
secondaryBgColor ?? defaultTheme.secondaryBgColor,
|
||||
this.iconColor = iconColor ?? defaultTheme.iconColor,
|
||||
this.selectedIconColor =
|
||||
selectedIconColor ?? defaultTheme.selectedIconColor,
|
||||
this.unselectedIconColor =
|
||||
unselectedIconColor ?? defaultTheme.unselectedIconColor,
|
||||
this.selectedTileDecoration =
|
||||
selectedTileDecoration ?? defaultTheme.selectedTileDecoration,
|
||||
this.unselectedTileDecoration =
|
||||
unselectedTileDecoration ?? defaultTheme.unselectedTileDecoration,
|
||||
this.boxDecoration = boxDecoration ?? defaultTheme.boxDecoration,
|
||||
this.labelTextStyle = labelTextStyle ?? defaultTheme.labelTextStyle,
|
||||
this.scrollPhysics = scrollPhysics ?? defaultTheme.scrollPhysics,
|
||||
this.tileMargin = tileMargin ?? defaultTheme.tileMargin,
|
||||
this.tilePadding = tilePadding ?? defaultTheme.tilePadding,
|
||||
this.gridCrossAxisCount =
|
||||
nbrTilesRow ?? defaultTheme.gridCrossAxisCount,
|
||||
this.heightFactor = heightFactor ?? defaultTheme.heightFactor,
|
||||
this.widthFactor = widthFactor ?? defaultTheme.widthFactor;
|
||||
|
||||
AvatarMakerThemeData copyWith({
|
||||
TextStyle? labelTextStyle,
|
||||
Color? primaryBgColor,
|
||||
Color? secondaryBgColor,
|
||||
Decoration? selectedTileDecoration,
|
||||
Decoration? unselectedTileDecoration,
|
||||
Color? iconColor,
|
||||
Color? selectedIconColor,
|
||||
Decoration? boxDecoration,
|
||||
ScrollPhysics? scrollPhysics,
|
||||
EdgeInsetsGeometry? tilePadding,
|
||||
EdgeInsetsGeometry? tileMargin,
|
||||
int? nbrTilesRow,
|
||||
double? heightFactor,
|
||||
double? widthFactor,
|
||||
}) {
|
||||
return AvatarMakerThemeData(
|
||||
labelTextStyle: labelTextStyle ?? this.labelTextStyle,
|
||||
primaryBgColor: primaryBgColor ?? this.primaryBgColor,
|
||||
secondaryBgColor: secondaryBgColor ?? this.secondaryBgColor,
|
||||
selectedTileDecoration:
|
||||
selectedTileDecoration ?? this.selectedTileDecoration,
|
||||
unselectedTileDecoration:
|
||||
unselectedTileDecoration ?? this.unselectedTileDecoration,
|
||||
iconColor: iconColor ?? this.iconColor,
|
||||
selectedIconColor: selectedIconColor ?? this.selectedIconColor,
|
||||
boxDecoration: boxDecoration ?? this.boxDecoration,
|
||||
scrollPhysics: scrollPhysics ?? this.scrollPhysics,
|
||||
tilePadding: tilePadding ?? this.tilePadding,
|
||||
tileMargin: tileMargin ?? this.tileMargin,
|
||||
nbrTilesRow: nbrTilesRow ?? this.gridCrossAxisCount,
|
||||
heightFactor: heightFactor ?? this.heightFactor,
|
||||
widthFactor: widthFactor ?? this.widthFactor,
|
||||
);
|
||||
}
|
||||
|
||||
/// Default theme of Avatar Maker components.
|
||||
static AvatarMakerThemeData defaultTheme = AvatarMakerThemeData(
|
||||
primaryBgColor: const Color(0xFFFFFFFF),
|
||||
secondaryBgColor: const Color(0xFFF1F1F1),
|
||||
iconColor: const Color(0xFF9C9C9C),
|
||||
selectedIconColor: const Color(0xFF424242),
|
||||
unselectedIconColor: const Color(0x80424242),
|
||||
selectedTileDecoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12.0),
|
||||
border: Border.all(
|
||||
color: Color(0xFF00FF00),
|
||||
width: 3.0,
|
||||
),
|
||||
),
|
||||
unselectedTileDecoration: BoxDecoration(),
|
||||
boxDecoration: BoxDecoration(borderRadius: BorderRadius.circular(18)),
|
||||
labelTextStyle:
|
||||
const TextStyle(fontWeight: FontWeight.w600, color: Colors.black),
|
||||
scrollPhysics: const ClampingScrollPhysics(),
|
||||
tileMargin: const EdgeInsets.all(2.0),
|
||||
tilePadding: const EdgeInsets.all(2.0),
|
||||
nbrTilesRow: 6,
|
||||
heightFactor: 0.4,
|
||||
widthFactor: 0.95,
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
import "package:avatar_maker/src/core/controllers/controllers.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:provider/provider.dart";
|
||||
|
||||
/// A provider widget for [AvatarMakerController].
|
||||
///
|
||||
/// This widget provides an [AvatarMakerController] to its descendants.
|
||||
/// If a controller is not provided, it will create a [PersistentAvatarMakerController]
|
||||
/// by default.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// AvatarMakerControllerProvider(
|
||||
/// controller: myController, // Optional
|
||||
/// child: MyWidget(),
|
||||
/// )
|
||||
/// ```
|
||||
class AvatarMakerControllerProvider extends StatelessWidget {
|
||||
/// The child widget that will have access to the controller.
|
||||
final Widget child;
|
||||
|
||||
/// The controller to provide to descendants.
|
||||
///
|
||||
/// If not provided, a [PersistentAvatarMakerController] will be created.
|
||||
final AvatarMakerController? controller;
|
||||
|
||||
/// Creates a provider for [AvatarMakerController].
|
||||
///
|
||||
/// The [child] parameter is required and represents the widget tree
|
||||
/// that will have access to the controller.
|
||||
///
|
||||
/// The [controller] parameter is optional. If not provided, a
|
||||
/// [PersistentAvatarMakerController] will be created.
|
||||
const AvatarMakerControllerProvider({
|
||||
Key? key,
|
||||
required this.child,
|
||||
this.controller,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ChangeNotifierProvider<AvatarMakerController>(
|
||||
create: (context) => controller ?? PersistentAvatarMakerController(),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
2
avatar_maker/lib/src/core/providers/providers.dart
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
// Export all providers from this directory
|
||||
export 'avatar_maker_controller_provider.dart';
|
||||
13
avatar_maker/lib/src/core/services/accessory_service.dart
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
/// Contains all the methods related to accessories.
|
||||
class AccessoryService {
|
||||
/// Draw the SVG of an accessory given in parameter.
|
||||
static String drawSVG({
|
||||
required String accessory,
|
||||
}) {
|
||||
return """
|
||||
<svg width="20px" height="20px" viewBox="-3 -50 120 170" >
|
||||
${accessory}
|
||||
</svg>
|
||||
""";
|
||||
}
|
||||
}
|
||||
144
avatar_maker/lib/src/core/services/avatar_service.dart
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import 'package:avatar_maker/src/core/enums/property_items/property_items.dart';
|
||||
import 'package:avatar_maker/src/core/enums/property_category_ids.dart';
|
||||
import 'package:avatar_maker/src/core/models/property_item.dart';
|
||||
|
||||
/// Contains all the methods related to avatars.
|
||||
class AvatarService {
|
||||
/// Draw the SVG of an avatar with all the selected options.
|
||||
static String drawSVG({
|
||||
required String backgroundStyle,
|
||||
required String outfit,
|
||||
required String facialHair,
|
||||
required String mouth,
|
||||
required String nose,
|
||||
required String eyes,
|
||||
required String eyebrows,
|
||||
required String accessory,
|
||||
required String hair,
|
||||
required String skin,
|
||||
}) {
|
||||
return """
|
||||
<svg width="264px" height="280px" viewBox="0 0 264 280" version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<desc>AvatarMaker on pub.dev</desc>
|
||||
<defs>
|
||||
<circle id="path-1" cx="120" cy="120" r="120"></circle>
|
||||
<path
|
||||
d="M12,160 C12,226.27417 65.72583,280 132,280 C198.27417,280 252,226.27417 252,160 L264,160 L264,-1.42108547e-14 L-3.19744231e-14,-1.42108547e-14 L-3.19744231e-14,160 L12,160 Z"
|
||||
id="path-3"></path>
|
||||
<path
|
||||
d="M124,144.610951 L124,163 L128,163 L128,163 C167.764502,163 200,195.235498 200,235 L200,244 L0,244 L0,235 C-4.86974701e-15,195.235498 32.235498,163 72,163 L72,163 L76,163 L76,144.610951 C58.7626345,136.422372 46.3722246,119.687011 44.3051388,99.8812385 C38.4803105,99.0577866 34,94.0521096 34,88 L34,74 C34,68.0540074 38.3245733,63.1180731 44,62.1659169 L44,56 L44,56 C44,25.072054 69.072054,5.68137151e-15 100,0 L100,0 L100,0 C130.927946,-5.68137151e-15 156,25.072054 156,56 L156,62.1659169 C161.675427,63.1180731 166,68.0540074 166,74 L166,88 C166,94.0521096 161.51969,99.0577866 155.694861,99.8812385 C153.627775,119.687011 141.237365,136.422372 124,144.610951 Z"
|
||||
id="path-5"></path>
|
||||
</defs>
|
||||
<g id="AvatarMaker" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g transform="translate(-825.000000, -1100.000000)" id="avatar_maker/Circle">
|
||||
<g transform="translate(825.000000, 1100.000000)">
|
||||
$backgroundStyle
|
||||
<g id="Mask"></g>
|
||||
<g id="AvatarMaker" stroke-width="1" fill-rule="evenodd">
|
||||
<g id="Body" transform="translate(32.000000, 36.000000)">
|
||||
<mask id="mask-6" fill="white">
|
||||
<use xlink:href="#path-5"></use>
|
||||
</mask>
|
||||
<use fill="#D0C6AC" xlink:href="#path-5"></use>
|
||||
$skin
|
||||
<path
|
||||
d="M156,79 L156,102 C156,132.927946 130.927946,158 100,158 C69.072054,158 44,132.927946 44,102 L44,79 L44,94 C44,124.927946 69.072054,150 100,150 C130.927946,150 156,124.927946 156,94 L156,79 Z"
|
||||
id="Neck-Shadow" opacity="0.100000001" fill="#000000"
|
||||
mask="url(#mask-6)"></path>
|
||||
</g>
|
||||
$outfit
|
||||
<g id="Face" transform="translate(76.000000, 82.000000)" fill="#000000">
|
||||
$mouth
|
||||
$facialHair
|
||||
$nose
|
||||
$eyes
|
||||
$eyebrows
|
||||
$accessory
|
||||
</g>
|
||||
$hair
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
""";
|
||||
}
|
||||
|
||||
/// Decode the SVG string to extract the selected options.
|
||||
static Map<PropertyCategoryIds, PropertyItem> extractPropertiesFromSvg(
|
||||
String svg) {
|
||||
// Initialize with default values
|
||||
Map<PropertyCategoryIds, PropertyItem> result = {
|
||||
PropertyCategoryIds.Background: BackgroundStyles.Transparent,
|
||||
PropertyCategoryIds.SkinColor: SkinColors.Brown,
|
||||
PropertyCategoryIds.OutfitType: OutfitTypes.Hoodie,
|
||||
PropertyCategoryIds.OutfitColor: OutfitColors.PastelBlue,
|
||||
PropertyCategoryIds.FacialHairType: FacialHairTypes.Nothing,
|
||||
PropertyCategoryIds.FacialHairColor: FacialHairColors.Black,
|
||||
PropertyCategoryIds.MouthType: Mouths.Default,
|
||||
PropertyCategoryIds.Nose: Noses.Default,
|
||||
PropertyCategoryIds.EyeType: Eyes.Default,
|
||||
PropertyCategoryIds.EyebrowType: Eyebrows.Default,
|
||||
PropertyCategoryIds.Accessory: Accessories.Nothing,
|
||||
PropertyCategoryIds.HairStyle: HairStyles.Bald,
|
||||
PropertyCategoryIds.HairColor: HairColors.Black,
|
||||
};
|
||||
|
||||
// Extract background style
|
||||
// Check for Circle background first (more specific)
|
||||
if (svg.contains(BackgroundStyles.Circle.value.trim())) {
|
||||
result[PropertyCategoryIds.Background] = BackgroundStyles.Circle;
|
||||
} else {
|
||||
// Default to Transparent if Circle is not found
|
||||
result[PropertyCategoryIds.Background] = BackgroundStyles.Transparent;
|
||||
}
|
||||
|
||||
final Map<PropertyCategoryIds, List<PropertyItem>> properties = {
|
||||
PropertyCategoryIds.Background: BackgroundStyles.values,
|
||||
PropertyCategoryIds.SkinColor: SkinColors.values,
|
||||
PropertyCategoryIds.OutfitType: OutfitTypes.values,
|
||||
// PropertyCategoryIds.OutfitColor: OutfitColors.values,
|
||||
PropertyCategoryIds.FacialHairType: FacialHairTypes.values,
|
||||
// PropertyCategoryIds.FacialHairColor: FacialHairColors.values,
|
||||
PropertyCategoryIds.MouthType: Mouths.values,
|
||||
PropertyCategoryIds.Nose: Noses.values,
|
||||
PropertyCategoryIds.EyeType: Eyes.values,
|
||||
PropertyCategoryIds.EyebrowType: Eyebrows.values,
|
||||
PropertyCategoryIds.Accessory: Accessories.values,
|
||||
PropertyCategoryIds.HairStyle: HairStyles.values,
|
||||
// PropertyCategoryIds.HairColor: HairColors.values,
|
||||
};
|
||||
|
||||
for (final category in properties.entries) {
|
||||
for (final item in category.value) {
|
||||
if (item.value.isEmpty) continue; // Skip empty values
|
||||
// Check if the SVG contains the ID of the item
|
||||
if (svg.contains('id="${item.id}"')) {
|
||||
result[category.key] = item;
|
||||
break; // Stop after finding the first match
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final Map<PropertyCategoryIds, List<PropertyItem>> colors = {
|
||||
PropertyCategoryIds.OutfitColor: OutfitColors.values,
|
||||
PropertyCategoryIds.FacialHairColor: FacialHairColors.values,
|
||||
PropertyCategoryIds.HairColor: HairColors.values,
|
||||
};
|
||||
|
||||
// Extract outfit color
|
||||
// Check for hex codes in the SVG where outfit color would be
|
||||
for (final item in colors.entries) {
|
||||
for (final color in item.value) {
|
||||
if (svg.contains(RegExp(
|
||||
'id="${RegExp.escape(color.id)}"[^>]*fill="${color.value}"'))) {
|
||||
result[item.key] = color;
|
||||
break; // Stop after finding the first match
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
27
avatar_maker/lib/src/core/services/background_service.dart
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
/// Contains all the methods related to backgrounds.
|
||||
class BackgroundService {
|
||||
/// Draw the SVG of a background given in parameter.
|
||||
static String drawSVG({
|
||||
required String background,
|
||||
}) {
|
||||
return """
|
||||
<svg width="264px" height="280px" viewBox="0 0 264 280" version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<defs>
|
||||
<circle id="path-1" cx="120" cy="120" r="120"></circle>
|
||||
<path
|
||||
d="M12,160 C12,226.27417 65.72583,280 132,280 C198.27417,280 252,226.27417 252,160 L264,160 L264,-1.42108547e-14 L-3.19744231e-14,-1.42108547e-14 L-3.19744231e-14,160 L12,160 Z"
|
||||
id="path-3"></path>
|
||||
</defs>
|
||||
<g id="AvatarMaker" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g transform="translate(-825.000000, -1100.000000)" id="avatar_maker/Circle">
|
||||
<g transform="translate(825.000000, 1100.000000)">${background}
|
||||
<g id="Mask"></g>
|
||||
<g id="AvatarMaker" stroke-width="1" fill-rule="evenodd"></g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
""";
|
||||
}
|
||||
}
|
||||
13
avatar_maker/lib/src/core/services/color_service.dart
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
/// Contains all the methods related to colors.
|
||||
class ColorService {
|
||||
/// Draw the SVG of a circle with color given in parameter.
|
||||
static String drawSVG({
|
||||
required String hexColorCode,
|
||||
}) {
|
||||
return """
|
||||
<svg width="120px" height="120px" >
|
||||
<circle cx="60" cy="60" r="35" stroke="black" stroke-width="1" fill="${hexColorCode}"/>
|
||||
</svg>
|
||||
""";
|
||||
}
|
||||
}
|
||||