This commit is contained in:
erik-everardo 2024-12-24 10:32:12 -06:00
parent a32757c518
commit 46e12db1fd
65 changed files with 642 additions and 2932 deletions

26
.idea/appInsightsSettings.xml generated Normal file
View File

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AppInsightsSettings">
<option name="tabSettings">
<map>
<entry key="Firebase Crashlytics">
<value>
<InsightsFilterSettings>
<option name="connection">
<ConnectionSetting>
<option name="appId" value="PLACEHOLDER" />
<option name="mobileSdkAppId" value="" />
<option name="projectId" value="" />
<option name="projectNumber" value="" />
</ConnectionSetting>
</option>
<option name="signal" value="SIGNAL_UNSPECIFIED" />
<option name="timeIntervalDays" value="THIRTY_DAYS" />
<option name="visibilityType" value="ALL" />
</InsightsFilterSettings>
</value>
</entry>
</map>
</option>
</component>
</project>

View File

@ -328,7 +328,7 @@
<PersistentState>
<option name="values">
<map>
<entry key="url" value="file:/$USER_HOME$/AppData/Local/Android/Sdk/icons/material/materialicons/article/baseline_article_24.xml" />
<entry key="url" value="file:/$USER_HOME$/AppData/Local/Android/Sdk/icons/material/materialicons/circle/baseline_circle_24.xml" />
</map>
</option>
</PersistentState>
@ -338,8 +338,8 @@
</option>
<option name="values">
<map>
<entry key="outputName" value="baseline_article_24" />
<entry key="sourceFile" value="C:\Users\erike\Downloads\hashtag-solid.svg" />
<entry key="outputName" value="baseline_circle_24" />
<entry key="sourceFile" value="C:\Users\erike\OneDrive\Imágenes\logo-openfire.svg" />
</map>
</option>
</PersistentState>

340
.idea/caches/deviceStreaming.xml generated Normal file
View File

@ -0,0 +1,340 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DeviceStreaming">
<option name="deviceSelectionList">
<list>
<PersistentDeviceSelectionData>
<option name="api" value="27" />
<option name="brand" value="DOCOMO" />
<option name="codename" value="F01L" />
<option name="id" value="F01L" />
<option name="manufacturer" value="FUJITSU" />
<option name="name" value="F-01L" />
<option name="screenDensity" value="360" />
<option name="screenX" value="720" />
<option name="screenY" value="1280" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="OPPO" />
<option name="codename" value="OP573DL1" />
<option name="id" value="OP573DL1" />
<option name="manufacturer" value="OPPO" />
<option name="name" value="CPH2557" />
<option name="screenDensity" value="480" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="28" />
<option name="brand" value="DOCOMO" />
<option name="codename" value="SH-01L" />
<option name="id" value="SH-01L" />
<option name="manufacturer" value="SHARP" />
<option name="name" value="AQUOS sense2 SH-01L" />
<option name="screenDensity" value="480" />
<option name="screenX" value="1080" />
<option name="screenY" value="2160" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="Lenovo" />
<option name="codename" value="TB370FU" />
<option name="id" value="TB370FU" />
<option name="manufacturer" value="Lenovo" />
<option name="name" value="Tab P12" />
<option name="screenDensity" value="340" />
<option name="screenX" value="1840" />
<option name="screenY" value="2944" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="31" />
<option name="brand" value="samsung" />
<option name="codename" value="a51" />
<option name="id" value="a51" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy A51" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="google" />
<option name="codename" value="akita" />
<option name="id" value="akita" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 8a" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="33" />
<option name="brand" value="samsung" />
<option name="codename" value="b0q" />
<option name="id" value="b0q" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy S22 Ultra" />
<option name="screenDensity" value="600" />
<option name="screenX" value="1440" />
<option name="screenY" value="3088" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="32" />
<option name="brand" value="google" />
<option name="codename" value="bluejay" />
<option name="id" value="bluejay" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 6a" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="google" />
<option name="codename" value="caiman" />
<option name="id" value="caiman" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 9 Pro" />
<option name="screenDensity" value="360" />
<option name="screenX" value="960" />
<option name="screenY" value="2142" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="google" />
<option name="codename" value="comet" />
<option name="id" value="comet" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 9 Pro Fold" />
<option name="screenDensity" value="390" />
<option name="screenX" value="2076" />
<option name="screenY" value="2152" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="29" />
<option name="brand" value="samsung" />
<option name="codename" value="crownqlteue" />
<option name="id" value="crownqlteue" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy Note9" />
<option name="screenDensity" value="420" />
<option name="screenX" value="2220" />
<option name="screenY" value="1080" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="samsung" />
<option name="codename" value="dm3q" />
<option name="id" value="dm3q" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy S23 Ultra" />
<option name="screenDensity" value="600" />
<option name="screenX" value="1440" />
<option name="screenY" value="3088" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="samsung" />
<option name="codename" value="e1q" />
<option name="id" value="e1q" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy S24" />
<option name="screenDensity" value="480" />
<option name="screenX" value="1080" />
<option name="screenY" value="2340" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="33" />
<option name="brand" value="google" />
<option name="codename" value="felix" />
<option name="id" value="felix" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel Fold" />
<option name="screenDensity" value="420" />
<option name="screenX" value="2208" />
<option name="screenY" value="1840" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="google" />
<option name="codename" value="felix" />
<option name="id" value="felix" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel Fold" />
<option name="screenDensity" value="420" />
<option name="screenX" value="2208" />
<option name="screenY" value="1840" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="33" />
<option name="brand" value="google" />
<option name="codename" value="felix_camera" />
<option name="id" value="felix_camera" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel Fold (Camera-enabled)" />
<option name="screenDensity" value="420" />
<option name="screenX" value="2208" />
<option name="screenY" value="1840" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="33" />
<option name="brand" value="samsung" />
<option name="codename" value="gts8uwifi" />
<option name="id" value="gts8uwifi" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy Tab S8 Ultra" />
<option name="screenDensity" value="320" />
<option name="screenX" value="1848" />
<option name="screenY" value="2960" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="google" />
<option name="codename" value="husky" />
<option name="id" value="husky" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 8 Pro" />
<option name="screenDensity" value="390" />
<option name="screenX" value="1008" />
<option name="screenY" value="2244" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="30" />
<option name="brand" value="motorola" />
<option name="codename" value="java" />
<option name="id" value="java" />
<option name="manufacturer" value="Motorola" />
<option name="name" value="G20" />
<option name="screenDensity" value="280" />
<option name="screenX" value="720" />
<option name="screenY" value="1600" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="google" />
<option name="codename" value="komodo" />
<option name="id" value="komodo" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 9 Pro XL" />
<option name="screenDensity" value="360" />
<option name="screenX" value="1008" />
<option name="screenY" value="2244" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="33" />
<option name="brand" value="google" />
<option name="codename" value="lynx" />
<option name="id" value="lynx" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 7a" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="31" />
<option name="brand" value="google" />
<option name="codename" value="oriole" />
<option name="id" value="oriole" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 6" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="33" />
<option name="brand" value="google" />
<option name="codename" value="panther" />
<option name="id" value="panther" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 7" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="samsung" />
<option name="codename" value="q5q" />
<option name="id" value="q5q" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy Z Fold5" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1812" />
<option name="screenY" value="2176" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="samsung" />
<option name="codename" value="q6q" />
<option name="id" value="q6q" />
<option name="manufacturer" value="Samsung" />
<option name="name" value="Galaxy Z Fold6" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1856" />
<option name="screenY" value="2160" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="30" />
<option name="brand" value="google" />
<option name="codename" value="r11" />
<option name="id" value="r11" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel Watch" />
<option name="screenDensity" value="320" />
<option name="screenX" value="384" />
<option name="screenY" value="384" />
<option name="type" value="WEAR_OS" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="30" />
<option name="brand" value="google" />
<option name="codename" value="redfin" />
<option name="id" value="redfin" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 5" />
<option name="screenDensity" value="440" />
<option name="screenX" value="1080" />
<option name="screenY" value="2340" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="google" />
<option name="codename" value="shiba" />
<option name="id" value="shiba" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 8" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2400" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="33" />
<option name="brand" value="google" />
<option name="codename" value="tangorpro" />
<option name="id" value="tangorpro" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel Tablet" />
<option name="screenDensity" value="320" />
<option name="screenX" value="1600" />
<option name="screenY" value="2560" />
</PersistentDeviceSelectionData>
<PersistentDeviceSelectionData>
<option name="api" value="34" />
<option name="brand" value="google" />
<option name="codename" value="tokay" />
<option name="id" value="tokay" />
<option name="manufacturer" value="Google" />
<option name="name" value="Pixel 9" />
<option name="screenDensity" value="420" />
<option name="screenX" value="1080" />
<option name="screenY" value="2424" />
</PersistentDeviceSelectionData>
</list>
</option>
</component>
</project>

2
.idea/compiler.xml generated
View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="17" />
<bytecodeTargetLevel target="21" />
</component>
</project>

23
.idea/deploymentTargetDropDown.xml generated Normal file
View File

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetDropDown">
<value>
<entry key="app">
<State>
<targetSelectedWithDropDown>
<Target>
<type value="QUICK_BOOT_TARGET" />
<deviceKey>
<Key>
<type value="VIRTUAL_DEVICE_PATH" />
<value value="C:\Users\erike\.android\avd\Pixel_7_API_34.avd" />
</Key>
</deviceKey>
</Target>
</targetSelectedWithDropDown>
<timeTargetWasSelectedWithDropDown value="2024-04-25T06:26:20.392506800Z" />
</State>
</entry>
</value>
</component>
</project>

18
.idea/deploymentTargetSelector.xml generated Normal file
View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetSelector">
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2024-05-05T02:26:13.673098400Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="LocalEmulator" identifier="path=C:\Users\erike\.android\avd\Pixel_7_API_34.avd" />
</handle>
</Target>
</DropdownSelection>
<DialogSelection />
</SelectionState>
</selectionStates>
</component>
</project>

1
.idea/gradle.xml generated
View File

@ -4,6 +4,7 @@
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="testRunner" value="CHOOSE_PER_TEST" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
<option name="modules">

View File

@ -0,0 +1,69 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="ComposePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewApiLevelMustBeValid" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewDeviceShouldUseNewSpec" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewFontScaleMustBeGreaterThanZero" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
</profile>
</component>

2
.idea/kotlinc.xml generated
View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KotlinJpsPluginSettings">
<option name="version" value="1.9.0" />
<option name="version" value="2.1.0" />
</component>
</project>

2
.idea/misc.xml generated
View File

@ -1,6 +1,6 @@
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="jbr-17" project-jdk-type="JavaSDK">
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">

98
.idea/navEditor.xml generated
View File

@ -323,6 +323,75 @@
</LayoutPositions>
</value>
</entry>
<entry key="messenger_navigation.xml">
<value>
<LayoutPositions>
<option name="myPositions">
<map>
<entry key="contactsFragment">
<value>
<LayoutPositions>
<option name="myPosition">
<Point>
<option name="x" value="-61" />
<option name="y" value="-20" />
</Point>
</option>
<option name="myPositions">
<map>
<entry key="action_contactsFragment_to_conversationFragment">
<value>
<LayoutPositions />
</value>
</entry>
<entry key="action_contactsFragment_to_registrationFragment">
<value>
<LayoutPositions />
</value>
</entry>
</map>
</option>
</LayoutPositions>
</value>
</entry>
<entry key="conversationFragment">
<value>
<LayoutPositions>
<option name="myPosition">
<Point>
<option name="x" value="151" />
<option name="y" value="-166" />
</Point>
</option>
</LayoutPositions>
</value>
</entry>
<entry key="registrationFragment">
<value>
<LayoutPositions>
<option name="myPosition">
<Point>
<option name="x" value="170" />
<option name="y" value="142" />
</Point>
</option>
<option name="myPositions">
<map>
<entry key="action_registrationFragment_to_contactsFragment">
<value>
<LayoutPositions />
</value>
</entry>
</map>
</option>
</LayoutPositions>
</value>
</entry>
</map>
</option>
</LayoutPositions>
</value>
</entry>
<entry key="picture_viewer_navigation.xml">
<value>
<LayoutPositions>
@ -384,6 +453,18 @@
<LayoutPositions>
<option name="myPositions">
<map>
<entry key="hashtagPostsFragment">
<value>
<LayoutPositions>
<option name="myPosition">
<Point>
<option name="x" value="345" />
<option name="y" value="45" />
</Point>
</option>
</LayoutPositions>
</value>
</entry>
<entry key="postListingFragment">
<value>
<LayoutPositions>
@ -517,11 +598,28 @@
<LayoutPositions />
</value>
</entry>
<entry key="action_mainFragment_to_qrCodeFragment">
<value>
<LayoutPositions />
</value>
</entry>
</map>
</option>
</LayoutPositions>
</value>
</entry>
<entry key="qrCodeFragment">
<value>
<LayoutPositions>
<option name="myPosition">
<Point>
<option name="x" value="12" />
<option name="y" value="12" />
</Point>
</option>
</LayoutPositions>
</value>
</entry>
<entry key="reportProfileFragment">
<value>
<LayoutPositions>

2
.idea/vcs.xml generated
View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
<mapping directory="" vcs="Git" />
</component>
</project>

View File

@ -2,7 +2,6 @@ plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
id 'kotlin-android'
id 'kotlin-kapt'
id 'com.google.dagger.hilt.android'
id 'org.jetbrains.kotlin.plugin.serialization' version '1.8.0'
id 'com.google.android.libraries.mapsplatform.secrets-gradle-plugin'
@ -10,6 +9,8 @@ plugins {
id 'com.google.gms.google-services'
id 'com.google.firebase.crashlytics'
id 'com.google.android.gms.oss-licenses-plugin'
id 'org.jetbrains.kotlin.plugin.compose' version '2.1.0'
id 'com.google.devtools.ksp'
}
android {
@ -23,10 +24,6 @@ android {
compose true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.2"
}
defaultConfig {
applicationId "com.isolaatti"
minSdk 24
@ -54,7 +51,7 @@ android {
}
dependencies {
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.3'
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.3'
implementation 'androidx.core:core-ktx:1.12.0'
implementation 'androidx.appcompat:appcompat:1.6.1'
@ -73,8 +70,8 @@ dependencies {
implementation "androidx.datastore:datastore-preferences:1.0.0"
// Hilt
implementation "com.google.dagger:hilt-android:2.47"
kapt "com.google.dagger:hilt-compiler:2.47"
implementation "com.google.dagger:hilt-android:2.53.1"
ksp "com.google.dagger:hilt-compiler:2.53.1"
// Retrofit
@ -120,11 +117,10 @@ dependencies {
}
// Room Database
def room_version = "2.5.2"
def room_version = "2.6.1"
implementation "androidx.room:room-runtime:$room_version"
annotationProcessor "androidx.room:room-compiler:$room_version"
kapt("androidx.room:room-compiler:$room_version")
implementation "androidx.room:room-ktx:2.5.2"
ksp("androidx.room:room-compiler:$room_version")
implementation "androidx.room:room-ktx:$room_version"
// Image viewer
implementation 'com.github.MikeOrtiz:TouchImageView:3.5'
@ -138,7 +134,7 @@ dependencies {
implementation 'com.github.androidmads:QRGenerator:1.0.1'
// Firebase
implementation(platform("com.google.firebase:firebase-bom:32.7.3"))
implementation(platform("com.google.firebase:firebase-bom:33.7.0"))
implementation("com.google.firebase:firebase-crashlytics")
implementation("com.google.firebase:firebase-analytics")
implementation("com.google.firebase:firebase-messaging")
@ -146,7 +142,7 @@ dependencies {
// OSS screen
implementation 'com.google.android.gms:play-services-oss-licenses:17.1.0'
def composeBom = platform('androidx.compose:compose-bom:2024.10.01')
def composeBom = platform('androidx.compose:compose-bom:2024.12.01')
implementation composeBom
androidTestImplementation composeBom
implementation 'androidx.compose.material3:material3'

View File

@ -49,10 +49,7 @@
<activity android:name=".images.picture_viewer.ui.PictureViewerActivity" android:theme="@style/Theme.Isolaatti"/>
<activity android:name=".sign_up.ui.SignUpActivity" android:theme="@style/Theme.Isolaatti"/>
<activity android:name=".images.image_maker.ui.ImageMakerActivity" android:theme="@style/Theme.Isolaatti"/>
<activity android:name=".images.image_chooser.ui.ImageChooserActivity" android:theme="@style/Theme.Isolaatti"/>
<activity android:name=".profile.ui.EditProfileActivity" android:theme="@style/Theme.Isolaatti" />
<activity android:name=".audio.recorder.ui.AudioRecorderActivity" android:theme="@style/Theme.Isolaatti" />
<activity android:name=".audio.audio_selector.ui.AudioSelectorActivity" android:theme="@style/Theme.Isolaatti" />
<activity android:name=".posting.posts.ui.PostInfoActivity" android:theme="@style/Theme.Isolaatti"/>
<activity android:name=".hashtags.ui.HashtagsPostsActivity" android:theme="@style/Theme.Isolaatti" />
<provider

View File

@ -1,12 +1,8 @@
package com.isolaatti.audio
import androidx.media3.common.Player
import com.isolaatti.audio.common.data.AudiosApi
import com.isolaatti.audio.common.data.AudiosRepositoryImpl
import com.isolaatti.audio.common.domain.AudiosRepository
import com.isolaatti.audio.drafts.data.AudioDraftsRepositoryImpl
import com.isolaatti.audio.drafts.data.AudiosDraftsDao
import com.isolaatti.audio.drafts.domain.repository.AudioDraftsRepository
import com.isolaatti.connectivity.RetrofitClient
import com.isolaatti.database.AppDatabase
import com.isolaatti.settings.domain.UserIdSetting
@ -24,17 +20,7 @@ class Module {
}
@Provides
fun provideAudiosRepository(audiosApi: AudiosApi, audiosDraftsDao: AudiosDraftsDao, userIdSetting: UserIdSetting): AudiosRepository {
return AudiosRepositoryImpl(audiosApi, audiosDraftsDao, userIdSetting)
}
@Provides
fun provideAudioDraftsDao(database: AppDatabase): AudiosDraftsDao {
return database.audioDrafts()
}
@Provides
fun provideAudioDraftsRepository(audiosDraftsDao: AudiosDraftsDao): AudioDraftsRepository {
return AudioDraftsRepositoryImpl(audiosDraftsDao)
fun provideAudiosRepository(audiosApi: AudiosApi, userIdSetting: UserIdSetting): AudiosRepository {
return AudiosRepositoryImpl(audiosApi, userIdSetting)
}
}

View File

@ -1,18 +0,0 @@
package com.isolaatti.audio.audio_selector.presentation
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.viewpager2.adapter.FragmentStateAdapter
import com.isolaatti.audio.audio_selector.ui.AudiosListSelectorFragment
class AudioSelectorFragmentAdapter(fragmentActivity: FragmentActivity) : FragmentStateAdapter(fragmentActivity) {
override fun getItemCount(): Int = 2
override fun createFragment(position: Int): Fragment {
return when(position) {
0 -> AudiosListSelectorFragment.getInstance()
1 -> AudiosListSelectorFragment.getInstanceForDrafts()
else -> Fragment()
}
}
}

View File

@ -1,66 +0,0 @@
package com.isolaatti.audio.audio_selector.presentation
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.isolaatti.audio.common.domain.Audio
import com.isolaatti.audio.common.domain.AudiosRepository
import com.isolaatti.audio.common.domain.Playable
import com.isolaatti.audio.drafts.domain.AudioDraft
import com.isolaatti.audio.drafts.domain.repository.AudioDraftsRepository
import com.isolaatti.auth.domain.AuthRepository
import com.isolaatti.common.SortingEnum
import com.isolaatti.utils.Resource
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class AudioSelectorViewModel @Inject constructor(
private val audiosRepository: AudiosRepository,
private val audioDraftsRepository: AudioDraftsRepository
) : ViewModel() {
val audios: MutableLiveData<List<Audio>> = MutableLiveData()
val drafts: MutableLiveData<List<AudioDraft>> = MutableLiveData()
val sorting: MutableLiveData<SortingEnum> = MutableLiveData()
var squadId: String? = null
val error: MutableLiveData<Resource.Error.ErrorType?> = MutableLiveData(null)
val loading: MutableLiveData<Boolean> = MutableLiveData()
fun setSorting(sort: SortingEnum) {
sorting.value = sort
}
fun getAudioDrafts() {
viewModelScope.launch {
audioDraftsRepository.getAudioDrafts().onEach {
drafts.postValue(it)
}
}
}
fun getAudios() {
if(squadId == null) {
viewModelScope.launch {
audiosRepository.getMyAudios(audios.value?.lastOrNull()?.id).onEach { resource ->
when(resource) {
is Resource.Error -> {
loading.postValue(false)
error.postValue(resource.errorType)
}
is Resource.Loading -> {
loading.postValue(true)
}
is Resource.Success -> {
loading.postValue(false)
audios.postValue(resource.data ?: listOf())
}
}
}.flowOn(Dispatchers.IO).launchIn(this)
}
}
}
}

View File

@ -1,91 +0,0 @@
package com.isolaatti.audio.audio_selector.ui
import android.os.Bundle
import androidx.activity.viewModels
import androidx.appcompat.widget.PopupMenu
import androidx.navigation.findNavController
import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.tabs.TabLayoutMediator
import com.isolaatti.R
import com.isolaatti.audio.audio_selector.presentation.AudioSelectorFragmentAdapter
import com.isolaatti.audio.audio_selector.presentation.AudioSelectorViewModel
import com.isolaatti.audio.recorder.ui.AudioRecorderContract
import com.isolaatti.common.IsolaattiBaseActivity
import com.isolaatti.common.SortingEnum
import com.isolaatti.databinding.ActivityAudioSelectorBinding
class AudioSelectorActivity : IsolaattiBaseActivity() {
companion object {
const val EXTRA_ID = "id"
const val EXTRA_FOR_SQUAD = "forSquad"
const val OUT_EXTRA_PLAYABLE = "playable"
}
private lateinit var binding: ActivityAudioSelectorBinding
private val viewModel: AudioSelectorViewModel by viewModels()
private val audioRecorderLauncher = registerForActivityResult(AudioRecorderContract()) {
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityAudioSelectorBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.viewpager.adapter = AudioSelectorFragmentAdapter(this)
TabLayoutMediator(binding.tabLayout, binding.viewpager) {tab, position ->
when(position) {
0 -> tab.text = getString(R.string.audios)
1 -> tab.text = getString(R.string.drafts)
}
}.attach()
binding.sort.setText(R.string.descending_by_name)
setupListeners()
}
private fun setupListeners() {
binding.toolbar.setNavigationOnClickListener {
finish()
}
binding.newAudio.setOnClickListener {
audioRecorderLauncher.launch(null)
}
binding.sort.setOnClickListener {
val popupMenu = PopupMenu(this, it)
popupMenu.inflate(R.menu.audios_sort_menu)
popupMenu.setOnMenuItemClickListener { menuItem ->
binding.sort.text = menuItem.title
when(menuItem.itemId) {
R.id.desc_by_name -> {
viewModel.setSorting(SortingEnum.DescendingByName)
true
}
R.id.asc_by_name -> {
viewModel.setSorting(SortingEnum.AscendingByName)
true
}
R.id.asc_by_creation_date -> {
viewModel.setSorting(SortingEnum.AscendingByCreationDate)
true
}
R.id.desc_by_creation_date -> {
viewModel.setSorting(SortingEnum.DescendingByCreationDate)
true
}
else -> false
}
}
popupMenu.show()
}
}
}

View File

@ -1,43 +0,0 @@
package com.isolaatti.audio.audio_selector.ui
import android.app.Activity
import android.content.Context
import android.content.Intent
import androidx.activity.result.contract.ActivityResultContract
import com.isolaatti.audio.common.domain.Audio
import com.isolaatti.audio.common.domain.Playable
import java.io.Serializable
/**
* Contract to select audio. Use SelectorConfig class to specify the context to use to select audio.
* Output: if user selected an audio or draft, it will be returned as result. Check type as convenient. Null if user cancelled
*/
class AudioSelectorContract : ActivityResultContract<AudioSelectorContract.SelectorConfig, Playable?>() {
/**
* @param forSquad audios source will be a specified squad in the id param
* @param id squad id, should be non null if forSquad is true
*/
data class SelectorConfig(val forSquad: Boolean, val id: String?): Serializable
override fun createIntent(context: Context, input: SelectorConfig): Intent {
val intent = Intent(context, AudioSelectorActivity::class.java)
intent.apply {
putExtra(AudioSelectorActivity.EXTRA_FOR_SQUAD, input.forSquad)
if(input.forSquad) {
putExtra(AudioSelectorActivity.EXTRA_ID, input.id as String)
}
}
return intent
}
override fun parseResult(resultCode: Int, intent: Intent?): Playable? {
if(resultCode == Activity.RESULT_OK) {
return intent?.extras?.getSerializable(AudioSelectorActivity.OUT_EXTRA_PLAYABLE) as? Playable
}
return null
}
}

View File

@ -1,105 +0,0 @@
package com.isolaatti.audio.audio_selector.ui
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import com.isolaatti.audio.audio_selector.presentation.AudioSelectorViewModel
import com.isolaatti.audio.audios_list.presentation.AudiosAdapter
import com.isolaatti.databinding.FragmentAudiosListSelectorBinding
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class AudiosListSelectorFragment : Fragment() {
private lateinit var binding: FragmentAudiosListSelectorBinding
private val viewModel: AudioSelectorViewModel by activityViewModels()
private var mode: Int = ARG_VAL_MODE_AUDIOS
private var squadId: String? = null
private lateinit var adapter: AudiosAdapter
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
arguments?.getInt(ARG_MODE)?.let { mode = it }
arguments?.getString(ARG_SQUAD_ID)?.let { squadId = it }
viewModel.squadId = squadId
when(mode) {
ARG_VAL_MODE_AUDIOS -> {
viewModel.getAudios()
}
ARG_VAL_MODE_DRAFTS -> {
viewModel.getAudioDrafts()
}
}
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = FragmentAudiosListSelectorBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
adapter = AudiosAdapter(
onPlayClick = {
},
onOptionsClick = {audio, button ->
false
}
)
binding.recycler.adapter = adapter
when(mode) {
ARG_VAL_MODE_AUDIOS -> {
viewModel.audios.observe(viewLifecycleOwner) {
adapter.setData(it)
}
}
ARG_VAL_MODE_DRAFTS -> {
viewModel.drafts.observe(viewLifecycleOwner) {
}
}
}
}
companion object {
private const val ARG_MODE = "mode"
private const val ARG_SQUAD_ID = "squadId"
private const val ARG_VAL_MODE_DRAFTS = 0
private const val ARG_VAL_MODE_AUDIOS = 1
fun getInstance(): AudiosListSelectorFragment {
return AudiosListSelectorFragment().apply {
arguments = Bundle().apply {
putInt(ARG_MODE, ARG_VAL_MODE_AUDIOS)
}
}
}
fun getInstanceForDrafts(): AudiosListSelectorFragment {
return AudiosListSelectorFragment().apply {
arguments = Bundle().apply {
putInt(ARG_MODE, ARG_VAL_MODE_DRAFTS)
}
}
}
fun getInstanceForSquad(squadId: String): AudiosListSelectorFragment {
return AudiosListSelectorFragment().apply {
arguments = Bundle().apply {
putString(ARG_SQUAD_ID, squadId)
}
}
}
}
}

View File

@ -1,154 +0,0 @@
package com.isolaatti.audio.audios_list.presentation
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.content.res.ResourcesCompat
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.ViewHolder
import coil.load
import com.isolaatti.R
import com.isolaatti.audio.common.domain.Audio
import com.isolaatti.databinding.AudioListItemBinding
class AudiosAdapter(
private val onPlayClick: ((audio: Audio) -> Unit),
private val onOptionsClick: ((audio: Audio, button: View) -> Boolean)
) : RecyclerView.Adapter<AudiosAdapter.AudiosViewHolder>() {
enum class Payload {
PlayStateChanged, IsLoadingChanged
}
private var data: List<Audio> = listOf()
private var currentPlaying: Audio? = null
fun setData(audios: List<Audio>) {
data = audios
notifyDataSetChanged()
}
fun setIsPlaying(isPlaying: Boolean, audio: Audio) {
if(audio == currentPlaying) {
val index = data.indexOf(audio)
if(index == -1) return
data[index].isPlaying = isPlaying
notifyItemChanged(index, Payload.PlayStateChanged)
return
}
val prevIndex = data.indexOf(currentPlaying)
if(prevIndex != -1) {
data[prevIndex].isPlaying = false
notifyItemChanged(prevIndex, Payload.PlayStateChanged)
}
val newIndex = data.indexOf(audio)
if(newIndex != -1) {
data[newIndex].isPlaying = isPlaying
notifyItemChanged(newIndex, Payload.PlayStateChanged)
}
currentPlaying = audio
}
fun setIsLoadingAudio(isLoading: Boolean, audio: Audio) {
if(audio == currentPlaying) {
val index = data.indexOf(audio)
if(index == -1) return
data[index].isLoading = isLoading
notifyItemChanged(index, Payload.IsLoadingChanged)
return
}
val prevIndex = data.indexOf(currentPlaying)
if(prevIndex != -1) {
data[prevIndex].isLoading = false
notifyItemChanged(prevIndex, Payload.IsLoadingChanged)
}
val newIndex = data.indexOf(audio)
if(newIndex != -1) {
data[newIndex].isLoading = isLoading
notifyItemChanged(newIndex, Payload.IsLoadingChanged)
}
currentPlaying = audio
}
fun updateAudioProgress(total: Int, progress: Int, audio: Audio) {
}
inner class AudiosViewHolder(val binding: AudioListItemBinding) : ViewHolder(binding.root)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AudiosViewHolder {
val binding = AudioListItemBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return AudiosViewHolder(binding)
}
override fun getItemCount(): Int {
return data.size
}
override fun onBindViewHolder(
holder: AudiosViewHolder,
position: Int,
payloads: MutableList<Any>
) {
val audio = data[position]
if(payloads.isEmpty()) {
holder.binding.audioName.text = audio.name
holder.binding.audioAuthor.text = audio.userName
holder.binding.thumbnail.load(audio.thumbnail)
holder.binding.audioItemOptionsButton.setOnClickListener {
onOptionsClick(audio, it)
}
holder.binding.playButton.icon = ResourcesCompat.getDrawable(
holder.itemView.resources,
if(audio.isPlaying) R.drawable.baseline_pause_24 else R.drawable.baseline_play_arrow_24,
null
)
holder.binding.loading.visibility = if(audio.isLoading) View.VISIBLE else View.GONE
holder.binding.playButton.setOnClickListener {
onPlayClick(audio)
}
return
}
// only updates play button
if(payloads.contains(Payload.PlayStateChanged)) {
holder.binding.playButton.icon = ResourcesCompat.getDrawable(
holder.itemView.resources,
if(audio.isPlaying) R.drawable.baseline_pause_24 else R.drawable.baseline_play_arrow_24,
null
)
}
if(payloads.contains(Payload.IsLoadingChanged)) {
holder.binding.loading.visibility = if(audio.isLoading) View.VISIBLE else View.GONE
}
}
override fun onBindViewHolder(holder: AudiosViewHolder, position: Int) {}
fun removeAudio(audio: Audio) {
val index = data.indexOf(audio)
if(index == -1) return
// TODO data should be modified from outside
data = data.toMutableList().apply {
removeAt(index)
}
notifyItemRemoved(index)
}
}

View File

@ -1,41 +0,0 @@
package com.isolaatti.audio.audios_list.presentation
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.isolaatti.audio.common.domain.Audio
import com.isolaatti.audio.common.domain.AudiosRepository
import com.isolaatti.utils.Resource
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class AudiosViewModel @Inject constructor(private val audiosRepository: AudiosRepository) : ViewModel() {
val resource: MutableLiveData<Resource<List<Audio>>> = MutableLiveData()
val audioRemoved: MutableLiveData<Audio?> = MutableLiveData()
fun loadAudios(userId: Int) {
viewModelScope.launch {
audiosRepository.getAudiosOfUser(userId, null).onEach {
resource.postValue(it)
}.flowOn(Dispatchers.IO).launchIn(this)
}
}
fun removeAudio(audio: Audio) {
viewModelScope.launch {
audiosRepository.deleteAudio(audio.id).onEach {
if(it is Resource.Success) {
audioRemoved.postValue(audio)
}
}.flowOn(Dispatchers.IO).launchIn(this)
}
}
}

View File

@ -1,173 +0,0 @@
package com.isolaatti.audio.audios_list.ui
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.widget.PopupMenu
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.isolaatti.R
import com.isolaatti.audio.audios_list.presentation.AudiosAdapter
import com.isolaatti.audio.audios_list.presentation.AudiosViewModel
import com.isolaatti.audio.common.domain.Audio
import com.isolaatti.audio.common.domain.Playable
import com.isolaatti.audio.player.AudioPlayerConnector
import com.isolaatti.common.ErrorMessageViewModel
import com.isolaatti.databinding.FragmentAudiosBinding
import com.isolaatti.utils.Resource
import dagger.hilt.android.AndroidEntryPoint
import kotlin.properties.Delegates
@AndroidEntryPoint
class AudiosFragment : Fragment() {
lateinit var viewBinding: FragmentAudiosBinding
private val viewModel: AudiosViewModel by viewModels()
private val errorViewModel: ErrorMessageViewModel by activityViewModels()
private val arguments: AudiosFragmentArgs by navArgs()
private lateinit var adapter: AudiosAdapter
private var privilegedUserId by Delegates.notNull<Int>()
private lateinit var audioPlayerConnector: AudioPlayerConnector
private var loadedFirstTime = false
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
viewBinding = FragmentAudiosBinding.inflate(inflater)
return viewBinding.root
}
private fun onDeleteAudio(audio: Audio) {
MaterialAlertDialogBuilder(requireContext())
.setMessage(R.string.delete_audio_message)
.setTitle(R.string.delete_audio_title)
.setPositiveButton(R.string.yes_continue) { _, _ ->
viewModel.removeAudio(audio)
}
.setNegativeButton(R.string.no, null)
.show()
}
private val onOptionsClick: ((audio: Audio, button: View) -> Boolean) = { audio, button ->
val popup = PopupMenu(requireContext(), button)
popup.menuInflater.inflate(R.menu.audio_item_menu, popup.menu)
if(audio.userId != privilegedUserId) {
popup.menu.removeItem(R.id.rename_item)
popup.menu.removeItem(R.id.delete_item)
popup.menu.removeItem(R.id.set_as_profile_audio)
}
popup.setOnMenuItemClickListener {
when(it.itemId) {
R.id.delete_item -> {
onDeleteAudio(audio)
true
}
else -> false
}
}
popup.show()
true
}
private val onAudioPlayClick: (audio: Audio) -> Unit = {
audioPlayerConnector.playPauseAudio(it)
}
private val audioPlayerConnectorListener: AudioPlayerConnector.Listener = object: AudioPlayerConnector.Listener {
override fun onPlaying(isPlaying: Boolean, audio: Playable) {
if(audio is Audio)
adapter.setIsPlaying(isPlaying, audio)
}
override fun isLoading(isLoading: Boolean, audio: Playable) {
if(audio is Audio)
adapter.setIsLoadingAudio(isLoading, audio)
}
override fun progressChanged(second: Int, audio: Playable) {
}
override fun durationChanged(duration: Int, audio: Playable) {
}
override fun onEnded(audio: Playable) {
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
audioPlayerConnector = AudioPlayerConnector(requireContext())
audioPlayerConnector.addListener(audioPlayerConnectorListener)
viewLifecycleOwner.lifecycle.addObserver(audioPlayerConnector)
adapter = AudiosAdapter(onPlayClick = onAudioPlayClick, onOptionsClick = onOptionsClick)
viewBinding.recyclerAudios.layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.VERTICAL, false)
viewBinding.recyclerAudios.adapter = adapter
setupObservers()
if(arguments.source == SOURCE_PROFILE) {
privilegedUserId = arguments.sourceId.toInt()
viewModel.loadAudios(privilegedUserId)
}
viewBinding.topAppBar.setNavigationOnClickListener {
findNavController().popBackStack()
}
}
private fun setupObservers() {
viewModel.resource.observe(viewLifecycleOwner) { resource ->
when(resource) {
is Resource.Error -> {
errorViewModel.error.postValue(resource.errorType)
}
is Resource.Loading -> {
if(!loadedFirstTime) {
viewBinding.progressBarLoading.visibility = View.VISIBLE
}
}
is Resource.Success -> {
viewBinding.progressBarLoading.visibility = View.GONE
loadedFirstTime = true
adapter.setData(resource.data!!)
}
}
}
viewModel.audioRemoved.observe(viewLifecycleOwner) {
if(it != null){
adapter.removeAudio(it)
viewModel.audioRemoved.value = null
}
}
}
companion object {
const val SOURCE_PROFILE = "source_profile"
const val SOURCE_SQUAD = "source_squads"
}
}

View File

@ -13,7 +13,6 @@ import retrofit2.http.Query
interface AudiosApi {
companion object {
const val AudioParam= "audioFile"
const val NameParam = "name"
const val DurationParam = "duration"
}
@GET("/api/Audios/OfUser/{userId}")
@ -21,7 +20,7 @@ interface AudiosApi {
@POST("/api/Audios/Create")
@Multipart
fun uploadFile(@Part file: MultipartBody.Part, @Part name: MultipartBody.Part, @Part duration: MultipartBody.Part): Call<AudioDto>
fun uploadFile(@Part file: MultipartBody.Part, @Part duration: MultipartBody.Part): Call<AudioDto>
@POST("/api/Audios/{audioId}/Delete")
fun deleteAudio(@Path("audioId") audioId: String): Call<ResultDto<String>>

View File

@ -1,10 +1,10 @@
package com.isolaatti.audio.common.data
import android.net.Uri
import android.util.Log
import com.isolaatti.MyApplication
import androidx.core.net.toFile
import com.isolaatti.audio.common.domain.Audio
import com.isolaatti.audio.common.domain.AudiosRepository
import com.isolaatti.audio.drafts.data.AudiosDraftsDao
import com.isolaatti.settings.domain.UserIdSetting
import com.isolaatti.utils.Resource
import kotlinx.coroutines.flow.Flow
@ -13,12 +13,10 @@ import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.asRequestBody
import retrofit2.awaitResponse
import java.io.File
import javax.inject.Inject
class AudiosRepositoryImpl @Inject constructor(
private val audiosApi: AudiosApi,
private val audiosDraftsDao: AudiosDraftsDao,
private val userIdSetting: UserIdSetting
) : AudiosRepository {
companion object {
@ -57,18 +55,12 @@ class AudiosRepositoryImpl @Inject constructor(
emit(_getAudiosOfUser(userId, lastId))
}
override fun uploadAudio(draftId: Long): Flow<Resource<Audio>> = flow {
val audioDraftEntity = audiosDraftsDao.getAudioDraftById(draftId)
if(audioDraftEntity == null) {
emit(Resource.Error(Resource.Error.ErrorType.NotFoundError, "draft not found"))
return@flow
}
override fun uploadAudio(uri: Uri, postId: Long): Flow<Resource<Audio>> = flow {
val file = File(MyApplication.myApp.filesDir, audioDraftEntity.audioLocalPath)
val file = uri.toFile()
if(!file.exists()) {
// remove draft, file was removed from file system for some reason
audiosDraftsDao.deleteDrafts(arrayOf(audioDraftEntity))
emit(Resource.Error(Resource.Error.ErrorType.OtherError, "File not found"))
return@flow
}
@ -77,10 +69,9 @@ class AudiosRepositoryImpl @Inject constructor(
val response = audiosApi.uploadFile(
MultipartBody.Part.createFormData(
AudiosApi.AudioParam, // api parameter name
audioDraftEntity.audioLocalPath.split("/")[1],
file.asRequestBody("audio/3gpp".toMediaType()) // actual file to be sent
uri.lastPathSegment,
file.asRequestBody("audio/aac".toMediaType()) // actual file to be sent
),
MultipartBody.Part.createFormData(AudiosApi.NameParam, audioDraftEntity.name),
MultipartBody.Part.createFormData(AudiosApi.DurationParam, "0")
).awaitResponse()
@ -88,7 +79,6 @@ class AudiosRepositoryImpl @Inject constructor(
val audioDto = response.body()
if(audioDto != null) {
Log.d(LOG_TAG, "emit audio dto")
audiosDraftsDao.deleteDrafts(arrayOf(audioDraftEntity))
emit(Resource.Success(Audio.fromDto(audioDto)))
}

View File

@ -1,5 +1,6 @@
package com.isolaatti.audio.common.domain
import android.net.Uri
import com.isolaatti.utils.Resource
import kotlinx.coroutines.flow.Flow
@ -7,7 +8,7 @@ interface AudiosRepository {
fun getAudiosOfUser(userId: Int, lastId: String?): Flow<Resource<List<Audio>>>
fun getMyAudios(lastId: String?): Flow<Resource<List<Audio>>>
fun uploadAudio(draftId: Long): Flow<Resource<Audio>>
fun uploadAudio(uri: Uri, postId: Long): Flow<Resource<Audio>>
fun deleteAudio(audioId: String): Flow<Resource<Boolean>>
}

View File

@ -1,9 +0,0 @@
package com.isolaatti.audio.common.domain
import com.isolaatti.utils.Resource
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
class UploadAudioUC @Inject constructor(private val audiosRepository: AudiosRepository) {
operator fun invoke(draftId: Long): Flow<Resource<Audio>> = audiosRepository.uploadAudio(draftId)
}

View File

@ -1,12 +0,0 @@
package com.isolaatti.audio.drafts.data
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "audio_drafts")
data class AudioDraftEntity(
@PrimaryKey(autoGenerate = true) val id: Long,
val name: String,
val audioLocalPath: String,
val sizeInBytes: Long
)

View File

@ -1,53 +0,0 @@
package com.isolaatti.audio.drafts.data
import android.util.Log
import com.isolaatti.MyApplication
import com.isolaatti.audio.drafts.domain.AudioDraft
import com.isolaatti.audio.drafts.domain.repository.AudioDraftsRepository
import com.isolaatti.utils.Resource
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import java.io.File
class AudioDraftsRepositoryImpl(private val audiosDraftsDao: AudiosDraftsDao) : AudioDraftsRepository {
override fun saveAudioDraft(name: String, relativePath: String, size: Long): Flow<AudioDraft> = flow {
val entity = AudioDraftEntity(0, name, relativePath, size)
val insertedEntityId = audiosDraftsDao.insertAudioDraft(entity)
emit(AudioDraft.fromEntity(entity.copy(id = insertedEntityId)))
}
override fun getAudioDrafts(): Flow<List<AudioDraft>> = flow {
emit(audiosDraftsDao.getDrafts().map { AudioDraft.fromEntity(it) })
}
override fun deleteDrafts(draftIds: LongArray): Flow<Boolean> = flow {
val drafts = audiosDraftsDao.getAudioDraftsByIds(*draftIds)
audiosDraftsDao.deleteDrafts(drafts)
try {
for(draft in drafts) {
File(MyApplication.myApp.applicationContext.filesDir, draft.audioLocalPath).delete()
}
} catch(securityException: SecurityException) {
Log.e("AudioDraftsRepositoryImpl", "Could not delete file\n${securityException.message}")
}
emit(true)
}
override fun renameDraft(draftId: Long, name: String): Flow<Boolean> = flow {
val rowsAffected = audiosDraftsDao.renameDraft(draftId, name)
emit(rowsAffected > 0)
}
override fun getAudioDraftById(draftId: Long): Flow<Resource<AudioDraft>> = flow {
val audioDraft = audiosDraftsDao.getAudioDraftById(draftId)
if(audioDraft == null) {
emit(Resource.Error(Resource.Error.ErrorType.NotFoundError, "Audio draft not found"))
} else {
emit(Resource.Success(AudioDraft.fromEntity(audioDraft)))
}
}
}

View File

@ -1,28 +0,0 @@
package com.isolaatti.audio.drafts.data
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Update
@Dao
interface AudiosDraftsDao {
@Insert
fun insertAudioDraft(audioDraftEntity: AudioDraftEntity): Long
@Query("SELECT * FROM audio_drafts WHERE id = :draftId")
fun getAudioDraftById(draftId: Long): AudioDraftEntity?
@Query("SELECT * FROM audio_drafts WHERE id in (:draftId)")
fun getAudioDraftsByIds(vararg draftId: Long): Array<AudioDraftEntity>
@Query("SELECT * FROM audio_drafts ORDER BY id DESC")
fun getDrafts(): List<AudioDraftEntity>
@Delete
fun deleteDrafts(draft: Array<AudioDraftEntity>)
@Query("UPDATE audio_drafts SET name = :name WHERE id = :id")
fun renameDraft(id: Long, name: String): Int
}

View File

@ -1,26 +0,0 @@
package com.isolaatti.audio.drafts.domain
import android.net.Uri
import androidx.core.net.toUri
import com.isolaatti.MyApplication
import com.isolaatti.audio.common.domain.Playable
import com.isolaatti.audio.drafts.data.AudioDraftEntity
import java.io.File
import java.io.Serializable
data class AudioDraft(val id: Long, val name: String, val localStorageRelativePath: String, val size: Long) : Playable(), Serializable {
override val thumbnail: String?
get() = null
override val uri: Uri
get() {
val appFiles = MyApplication.myApp.applicationContext.filesDir
return File(appFiles, localStorageRelativePath).toUri()
}
companion object {
fun fromEntity(audioDraftEntity: AudioDraftEntity): AudioDraft {
return AudioDraft(audioDraftEntity.id, audioDraftEntity.name, audioDraftEntity.audioLocalPath, audioDraftEntity.sizeInBytes)
}
}
}

View File

@ -1,17 +0,0 @@
package com.isolaatti.audio.drafts.domain.repository
import com.isolaatti.audio.drafts.domain.AudioDraft
import com.isolaatti.utils.Resource
import kotlinx.coroutines.flow.Flow
interface AudioDraftsRepository {
fun saveAudioDraft(name: String, relativePath: String, size: Long): Flow<AudioDraft>
fun getAudioDrafts(): Flow<List<AudioDraft>>
fun deleteDrafts(draftIds: LongArray): Flow<Boolean>
fun renameDraft(draftId: Long, name: String): Flow<Boolean>
fun getAudioDraftById(draftId: Long): Flow<Resource<AudioDraft>>
}

View File

@ -1,12 +0,0 @@
package com.isolaatti.audio.drafts.domain.use_case
import com.isolaatti.audio.drafts.domain.AudioDraft
import com.isolaatti.audio.drafts.domain.repository.AudioDraftsRepository
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
class SaveAudioDraft @Inject constructor(private val audioDraftsRepository: AudioDraftsRepository) {
operator fun invoke(name: String, relativePath: String, size: Long): Flow<AudioDraft> {
return audioDraftsRepository.saveAudioDraft(name, relativePath, size)
}
}

View File

@ -1,55 +0,0 @@
package com.isolaatti.audio.drafts.presentation
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.content.res.ResourcesCompat
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView.ViewHolder
import com.isolaatti.R
import com.isolaatti.audio.drafts.domain.AudioDraft
import com.isolaatti.databinding.AudioListItemBinding
class AudioDraftsAdapter(
private val onOptionsClicked: (item: AudioDraft, view: View) -> Unit = { _,_ -> },
private val onPlayClicked: (item: AudioDraft , view: View) -> Unit = { _,_ -> },
private val onItemClicked: (item: AudioDraft , view: View) -> Unit = { _,_ -> }
) : ListAdapter<AudioDraft, AudioDraftsAdapter.AudioDraftViewHolder>(diffCallback) {
inner class AudioDraftViewHolder(val audioListItemBinding: AudioListItemBinding) : ViewHolder(audioListItemBinding.root)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AudioDraftViewHolder {
return AudioDraftViewHolder(
AudioListItemBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
)
}
override fun onBindViewHolder(holder: AudioDraftViewHolder, position: Int) {
val item = getItem(position)
holder.audioListItemBinding.apply {
audioName.text = item.name
audioItemOptionsButton.setOnClickListener { onOptionsClicked(item, it) }
playButton.icon = ResourcesCompat.getDrawable(
holder.itemView.resources,
if(item.isPlaying) R.drawable.baseline_pause_24 else R.drawable.baseline_play_arrow_24,
null
)
}
}
companion object {
val diffCallback = object: DiffUtil.ItemCallback<AudioDraft>() {
override fun areItemsTheSame(oldItem: AudioDraft, newItem: AudioDraft): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: AudioDraft, newItem: AudioDraft): Boolean {
return oldItem == newItem
}
}
}
}

View File

@ -1,6 +0,0 @@
package com.isolaatti.audio.drafts.presentation
import androidx.lifecycle.ViewModel
class AudioDraftsViewModel : ViewModel() {
}

View File

@ -1,49 +0,0 @@
package com.isolaatti.audio.drafts.ui
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.LinearLayoutManager
import com.isolaatti.audio.drafts.domain.AudioDraft
import com.isolaatti.audio.drafts.presentation.AudioDraftsAdapter
import com.isolaatti.audio.drafts.presentation.AudioDraftsViewModel
import com.isolaatti.databinding.FragmentAudioDraftsBinding
class AudioDraftsFragment : Fragment() {
private lateinit var binding: FragmentAudioDraftsBinding
private val viewModel: AudioDraftsViewModel by viewModels()
private var adapter: AudioDraftsAdapter? = null
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = FragmentAudioDraftsBinding.inflate(inflater)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
adapter = AudioDraftsAdapter(
onPlayClicked = { item: AudioDraft, view: View ->
},
onOptionsClicked = { item, button ->
}
)
binding.recycler.adapter = adapter
binding.recycler.layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.VERTICAL, false)
}
}

View File

@ -1,206 +0,0 @@
package com.isolaatti.audio.player
import android.content.Context
import android.net.Uri
import android.os.Looper
import android.util.Log
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner
import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.drm.DrmSessionManagerProvider
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy
import com.isolaatti.audio.common.domain.Audio
import com.isolaatti.audio.common.domain.Playable
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.util.Timer
import java.util.TimerTask
import java.util.concurrent.Executors
import java.util.concurrent.ScheduledFuture
import java.util.concurrent.TimeUnit
class AudioPlayerConnector(
private val context: Context
): LifecycleEventObserver {
companion object {
const val TAG = "AudioPlayerConnector"
}
private var player: Player? = null
private var audio: Playable? = null
private var mediaItem: MediaItem? = null
private var ended = false
private val listeners: MutableList<Listener> = mutableListOf()
private val scheduleExecutorService = Executors.newScheduledThreadPool(1)
private var timerFuture: ScheduledFuture<*>? = null
private val timerRunnable = Runnable {
CoroutineScope(Dispatchers.Main).launch {
audio?.let {
val progress = (player?.currentPosition)
if(progress != null) {
listeners.forEach { listener -> listener.progressChanged((progress / 1000).toInt(), it) }
}
}
}
}
private fun startTimer() {
Log.d(TAG, "startTimer()")
if(timerFuture == null) {
timerFuture = scheduleExecutorService.scheduleAtFixedRate(timerRunnable, 0, 100, TimeUnit.MILLISECONDS)
}
}
private fun stopTimer() {
timerFuture?.cancel(false)
timerFuture = null
}
private val playerListener: Player.Listener = object: Player.Listener {
override fun onPlayerError(error: PlaybackException) {
Log.e(TAG, error.message.toString())
}
override fun onIsPlayingChanged(isPlaying: Boolean) {
if(audio != null) {
listeners.forEach { listener -> listener.onPlaying(isPlaying, audio!!)}
}
if(isPlaying) {
ended = false
}
}
override fun onIsLoadingChanged(isLoading: Boolean) {
audio?.let {
listeners.forEach { listener -> listener.isLoading(isLoading, it) }
}
}
override fun onPlaybackStateChanged(playbackState: Int) {
when(playbackState) {
Player.STATE_ENDED -> {
Log.d(TAG, "STATE_ENDED")
audio?.let {
listeners.forEach { listener ->
listener.onPlaying(false, it)
listener.onEnded(it)
}
}
stopTimer()
ended = true
}
Player.STATE_BUFFERING -> {
player?.totalBufferedDuration?.let {
val seconds = (it / 1000).toInt()
Log.d(TAG, "Duration $it")
audio?.let {
listeners.forEach { listener -> listener.durationChanged(seconds, it) }
}
}
}
Player.STATE_IDLE -> {}
Player.STATE_READY -> {
Log.d(TAG, "STATE_READY")
player?.totalBufferedDuration?.let {
val seconds = (it / 1000).toInt()
Log.d(TAG, "Duration $it")
audio?.let {
listeners.forEach { listener -> listener.durationChanged(seconds, it) }
}
}
startTimer()
}
}
}
}
private fun initializePlayer() {
player = ExoPlayer.Builder(context).build()
player?.addListener(playerListener)
player?.prepare()
}
fun addListener(listener: Listener) {
listeners.add(listener)
}
private fun releasePlayer() {
player?.run {
release()
}
player = null
stopTimer()
}
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
Log.d(TAG, event.toString())
when(event) {
Lifecycle.Event.ON_RESUME -> {
if(player == null) {
initializePlayer()
}
}
Lifecycle.Event.ON_DESTROY -> {
releasePlayer()
listeners.clear()
}
else -> {}
}
}
fun playPauseAudio(audio: Playable) {
// intention is to pause current audio
if(audio == this.audio && player?.isPlaying == true) {
player?.pause()
return
} else if(audio == this.audio) {
if(ended) {
player?.seekTo(0)
ended = false
return
}
player?.play()
return
}
this.audio = audio
mediaItem = MediaItem.fromUri(audio.uri)
player?.setMediaItem(mediaItem!!)
player?.playWhenReady = true
}
fun stopPlayback() {
ended = true
player?.pause()
stopTimer()
}
interface Listener {
fun onPlaying(isPlaying: Boolean, audio: Playable)
fun isLoading(isLoading: Boolean, audio: Playable)
fun progressChanged(second: Int, audio: Playable)
fun durationChanged(duration: Int, audio: Playable)
fun onEnded(audio: Playable)
}
open class DefaultListener() : Listener {
override fun onPlaying(isPlaying: Boolean, audio: Playable) {}
override fun isLoading(isLoading: Boolean, audio: Playable) {}
override fun progressChanged(second: Int, audio: Playable) {}
override fun durationChanged(duration: Int, audio: Playable) {}
override fun onEnded(audio: Playable) {}
}
}

View File

@ -1,7 +0,0 @@
package com.isolaatti.audio.player
import androidx.media3.common.Player
abstract class PlayerFactory {
abstract fun MakePlayer(): Player
}

View File

@ -1,54 +0,0 @@
package com.isolaatti.audio.recorder.presentation
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.isolaatti.audio.drafts.domain.AudioDraft
import com.isolaatti.audio.drafts.domain.use_case.SaveAudioDraft
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import java.util.UUID
import javax.inject.Inject
@HiltViewModel
class AudioRecorderViewModel @Inject constructor(
private val saveAudioDraftUC: SaveAudioDraft
) : ViewModel() {
var name: String = ""
set(value) {
field = value
validate()
}
var relativePath = ""
var size: Long = 0
private var audioRecorded = false
val audioDraft: MutableLiveData<AudioDraft> = MutableLiveData()
val canSave: MutableLiveData<Boolean> = MutableLiveData()
private fun validate() {
canSave.value = audioRecorded && name.isNotBlank()
}
fun onAudioRecorded() {
audioRecorded = true
validate()
}
fun onClearAudio() {
audioRecorded = false
validate()
}
fun saveAudioDraft() {
viewModelScope.launch {
saveAudioDraftUC(name, relativePath, size).onEach {
audioDraft.postValue(it)
}.flowOn(Dispatchers.IO).launchIn(this)
}
}
}

View File

@ -1,21 +0,0 @@
package com.isolaatti.audio.recorder.presentation
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.viewpager2.adapter.FragmentStateAdapter
import com.isolaatti.audio.drafts.ui.AudioDraftsFragment
import com.isolaatti.audio.recorder.ui.AudioRecorderHelperNotesFragment
class RecorderPagerAdapter(fragmentActivity: FragmentActivity) : FragmentStateAdapter(fragmentActivity) {
override fun getItemCount(): Int {
return 2
}
override fun createFragment(position: Int): Fragment {
return when (position) {
0 -> AudioRecorderHelperNotesFragment()
1 -> AudioDraftsFragment()
else -> Fragment()
}
}
}

View File

@ -1,337 +0,0 @@
package com.isolaatti.audio.recorder.ui
import android.Manifest
import android.content.Intent
import android.content.res.ColorStateList
import android.content.res.Resources.Theme
import android.media.AudioManager
import android.media.MediaRecorder
import android.net.Uri
import android.os.Bundle
import android.provider.Settings
import android.util.Log
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.core.content.PermissionChecker
import androidx.core.content.PermissionChecker.PERMISSION_GRANTED
import androidx.core.content.res.ResourcesCompat
import androidx.core.net.toUri
import androidx.core.view.get
import androidx.core.widget.doOnTextChanged
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.dialog.MaterialDialogs
import com.google.android.material.tabs.TabLayoutMediator
import com.isolaatti.R
import com.isolaatti.audio.common.domain.Playable
import com.isolaatti.audio.drafts.domain.AudioDraft
import com.isolaatti.audio.player.AudioPlayerConnector
import com.isolaatti.audio.recorder.presentation.AudioRecorderViewModel
import com.isolaatti.audio.recorder.presentation.RecorderPagerAdapter
import com.isolaatti.databinding.ActivityAudioRecorderBinding
import com.isolaatti.utils.Resource
import com.isolaatti.utils.clockFormat
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.io.File
import java.io.IOException
import java.util.UUID
@AndroidEntryPoint
class AudioRecorderActivity : AppCompatActivity() {
companion object {
const val LOG_TAG = "AudioRecorderActivity"
const val IN_EXTRA_DRAFT_ID = "in_draft_id"
const val OUT_EXTRA_DRAFT = "out_draft"
}
private lateinit var binding: ActivityAudioRecorderBinding
private var audioRecorder: MediaRecorder? = null
private var recordingPaused = false
private var audioPlayerConnector: AudioPlayerConnector? = null
private val viewModel: AudioRecorderViewModel by viewModels()
private lateinit var outputFile: String
private val requestAudioPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) {
if(it) {
startRecording()
} else {
showPermissionRationale()
}
}
private val audioPlayerListener = object: AudioPlayerConnector.DefaultListener() {
override fun onPlaying(isPlaying: Boolean, audio: Playable) {
if(isPlaying) {
binding.seekbar.isEnabled = true
binding.playPauseButton.icon = ResourcesCompat.getDrawable(resources, R.drawable.baseline_pause_24, theme)
} else {
binding.playPauseButton.icon = ResourcesCompat.getDrawable(resources, R.drawable.baseline_play_arrow_24, theme)
}
}
override fun onEnded(audio: Playable) {
binding.seekbar.isEnabled = false
binding.seekbar.progress = 0
}
override fun durationChanged(duration: Int, audio: Playable) {
super.durationChanged(duration, audio)
binding.seekbar.max = duration
totalTime = duration
}
override fun progressChanged(second: Int, audio: Playable) {
binding.seekbar.progress = second
setDisplayTime(second, true)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityAudioRecorderBinding.inflate(layoutInflater)
setContentView(binding.root)
// Path where results are stored
File("${filesDir.absolutePath}/audios/").let {
if(!it.isDirectory) {
it.mkdir()
}
}
binding.seekbar.isEnabled = false
outputFile = "${cacheDir.absolutePath}/audio_recorder.3gp"
setupListeners()
setupObservers()
audioPlayerConnector = AudioPlayerConnector(this)
audioPlayerConnector?.addListener(audioPlayerListener)
lifecycle.addObserver(audioPlayerConnector!!)
}
private fun checkRecordAudioPermission(): Boolean {
return when {
PermissionChecker.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) == PERMISSION_GRANTED -> true
ActivityCompat.shouldShowRequestPermissionRationale(this, Manifest.permission.RECORD_AUDIO) -> {
showPermissionRationale()
false
}
else -> {
requestAudioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
false
}
}
}
private fun showPermissionRationale() {
MaterialAlertDialogBuilder(this)
.setTitle("Record audio permission")
.setMessage("We need permission to access your microphone so that you can record your audio. Go to settings.")
.setPositiveButton("Go to settings"){_, _ ->
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
intent.data = Uri.fromParts("package", packageName, null)
startActivity(intent)
}
.setNegativeButton("No, thanks", null)
.show()
}
private fun setupListeners() {
binding.recordButton.setOnClickListener {
if(checkRecordAudioPermission()) {
Log.d(LOG_TAG, "Starts recording")
startRecording()
} else {
Log.d(LOG_TAG, "Failed to start recording: mic permission not granted")
}
}
binding.stopRecording.setOnClickListener {
stopRecording()
}
binding.cancelButton.setOnClickListener {
discardRecording()
}
binding.pauseRecording.setOnClickListener {
pauseRecording()
}
binding.playPauseButton.setOnClickListener {
playPauseRecording(object: Playable() {
override val uri: Uri
get() = outputFile.toUri()
override val thumbnail: String?
get() = null
})
}
binding.inputDraftName.editText?.doOnTextChanged { text, _, _, _ ->
viewModel.name = text.toString()
}
binding.toolbar.setOnMenuItemClickListener {
when(it.itemId) {
R.id.save_draft -> {
viewModel.relativePath = "/audios/${UUID.randomUUID()}.3gp"
File(outputFile).run {
viewModel.size = length()
copyTo(File(filesDir.absolutePath, viewModel.relativePath), overwrite = true)
}
viewModel.saveAudioDraft()
true
}
else -> false
}
}
binding.toolbar.setNavigationOnClickListener {
finish()
}
}
private fun setupObservers() {
viewModel.canSave.observe(this) {
binding.toolbar.menu.findItem(R.id.save_draft).isEnabled = it
}
// audio draft is saved!
viewModel.audioDraft.observe(this) {
val result = Intent().apply {
putExtra(OUT_EXTRA_DRAFT, it)
}
setResult(RESULT_OK, result)
finish()
}
}
// region timer
private var timer: Job? = null
private var timerValue = 0
private var totalTime = 0
private fun setDisplayTime(seconds: Int, showTotalTime: Boolean) {
binding.time.text = buildString {
append(seconds.clockFormat())
if(showTotalTime) {
append("/")
append(totalTime.clockFormat())
}
}
}
private fun startTimerRecorder() {
timer = CoroutineScope(Dispatchers.Main).launch {
setDisplayTime(timerValue, false)
delay(1000)
timerValue++
startTimerRecorder()
}
}
private fun stopTimer() {
timer?.cancel()
}
// end region
// region record functions
private fun startRecording() {
viewModel.onClearAudio()
recordingPaused = false
audioRecorder = MediaRecorder().apply {
setAudioSource(MediaRecorder.AudioSource.MIC)
setOutputFormat(MediaRecorder.OutputFormat.THREE_GPP)
setOutputFile(outputFile)
setAudioEncoder(MediaRecorder.AudioEncoder.AMR_NB)
try {
prepare()
start()
timerValue = 0
startTimerRecorder()
binding.viewAnimator.displayedChild = 1
} catch(e: IOException) {
Log.e(LOG_TAG, "prepare() failed\n${e.message}")
}
}
}
private fun stopRecording() {
audioRecorder?.apply {
stop()
release()
}
stopTimer()
audioRecorder = null
// shows third state: audio recorded
binding.viewAnimator.displayedChild = 2
recordingPaused = false
binding.pauseRecording.icon = ResourcesCompat.getDrawable(resources, R.drawable.baseline_pause_24, theme)
binding.pauseRecording.setIconTintResource(com.google.android.material.R.color.m3_icon_button_icon_color_selector)
viewModel.onAudioRecorded()
}
private fun pauseRecording() {
if(recordingPaused) {
binding.pauseRecording.icon = ResourcesCompat.getDrawable(resources, R.drawable.baseline_pause_24, theme)
binding.pauseRecording.setIconTintResource(com.google.android.material.R.color.m3_icon_button_icon_color_selector)
audioRecorder?.resume()
startTimerRecorder()
} else {
binding.pauseRecording.icon = ResourcesCompat.getDrawable(resources, R.drawable.baseline_circle_24, theme)
binding.pauseRecording.setIconTintResource(R.color.danger)
audioRecorder?.pause()
stopTimer()
}
recordingPaused = !recordingPaused
}
private fun discardRecording(){
File(outputFile).apply {
try {
delete()
} catch(e: SecurityException) {
Log.e(LOG_TAG, "Could not delete file\n${e.message}")
}
}
binding.viewAnimator.displayedChild = 0
viewModel.onClearAudio()
totalTime = 0
timerValue = 0
binding.time.text = ""
audioPlayerConnector?.stopPlayback()
}
// end region
private fun playPauseRecording(audioDraft: Playable) {
audioPlayerConnector?.playPauseAudio(audioDraft)
}
}

View File

@ -1,29 +0,0 @@
package com.isolaatti.audio.recorder.ui
import android.app.Activity
import android.content.Context
import android.content.Intent
import androidx.activity.result.contract.ActivityResultContract
import com.isolaatti.audio.drafts.domain.AudioDraft
class AudioRecorderContract : ActivityResultContract<Long?, AudioDraft?>() {
override fun createIntent(context: Context, input: Long?): Intent {
val intent = Intent(context, AudioRecorderActivity::class.java).apply {
if(input != null) {
putExtra(AudioRecorderActivity.IN_EXTRA_DRAFT_ID, input)
}
}
return intent
}
override fun parseResult(resultCode: Int, intent: Intent?): AudioDraft? {
return when(resultCode){
Activity.RESULT_OK -> {
intent?.getSerializableExtra(AudioRecorderActivity.OUT_EXTRA_DRAFT) as? AudioDraft
}
else -> null
}
}
}

View File

@ -1,25 +0,0 @@
package com.isolaatti.audio.recorder.ui
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.TextView
import androidx.fragment.app.Fragment
class AudioRecorderHelperNotesFragment : Fragment() {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return LinearLayout(requireContext()).apply {
addView(TextView(requireContext()).apply {
text = "Allow the user to place some info here to be " +
"reading while recording. If this screen is launched from comments, show here the post"
})
}
}
}

View File

@ -2,8 +2,6 @@ package com.isolaatti.database
import androidx.room.Database
import androidx.room.RoomDatabase
import com.isolaatti.audio.drafts.data.AudioDraftEntity
import com.isolaatti.audio.drafts.data.AudiosDraftsDao
import com.isolaatti.auth.data.local.UserInfoDao
import com.isolaatti.auth.data.local.UserInfoEntity
import com.isolaatti.images.common.data.dao.ImagesDraftsDao
@ -13,11 +11,10 @@ import com.isolaatti.search.data.SearchHistoryEntity
import com.isolaatti.settings.data.KeyValueDao
import com.isolaatti.settings.data.KeyValueEntity
@Database(entities = [KeyValueEntity::class, UserInfoEntity::class, AudioDraftEntity::class, SearchHistoryEntity::class, ImageDraftEntity::class], version = 8)
@Database(entities = [KeyValueEntity::class, UserInfoEntity::class, SearchHistoryEntity::class, ImageDraftEntity::class], version = 9)
abstract class AppDatabase : RoomDatabase() {
abstract fun keyValueDao(): KeyValueDao
abstract fun userInfoDao(): UserInfoDao
abstract fun audioDrafts(): AudiosDraftsDao
abstract fun searchHistoryDao(): SearchDao
abstract fun imagesDraftsDao(): ImagesDraftsDao
}

View File

@ -91,18 +91,24 @@ fun ImagesRow(
Card(modifier = Modifier
.padding(horizontal = 4.dp)
.size(120.dp)) {
.size(120.dp),
onClick = {
onClick(index)
}) {
Box(
modifier = Modifier.fillMaxSize(),
) {
FilledTonalIconButton(
onClick = { onDeleteClick(index) },
modifier = Modifier.zIndex(2f)
) {
Icon(imageVector = Icons.Default.Close, contentDescription = null)
if(deletable) {
FilledTonalIconButton(
onClick = { onDeleteClick(index) },
modifier = Modifier.zIndex(2f)
) {
Icon(imageVector = Icons.Default.Close, contentDescription = null)
}
}
AsyncImage(
model = images[index],
contentDescription = null,
@ -118,5 +124,5 @@ fun ImagesRow(
@Preview(device = Devices.PIXEL_5)
@Composable
fun ImagesRowPreview() {
ImagesRow(images = emptyList(), deletable = false, addable = true, onClick = {})
ImagesRow(images = emptyList(), deletable = true, addable = true, onClick = {})
}

View File

@ -1,34 +0,0 @@
package com.isolaatti.images.image_chooser.presentation
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.isolaatti.auth.domain.AuthRepository
import com.isolaatti.images.common.domain.entity.Image
import com.isolaatti.images.common.domain.repository.ImagesRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class ImageChooserViewModel @Inject constructor(private val authRepository: AuthRepository) : ViewModel() {
val userId: MutableLiveData<Int> = MutableLiveData()
var selectedImage: Image? = null
val choose: MutableLiveData<Boolean> = MutableLiveData()
fun getUserId() {
viewModelScope.launch {
authRepository.getUserId().onEach {
userId.postValue(it)
}.flowOn(Dispatchers.IO).launchIn(this)
}
}
}

View File

@ -1,43 +0,0 @@
package com.isolaatti.images.image_chooser.ui
import android.content.Intent
import android.os.Bundle
import androidx.activity.viewModels
import com.isolaatti.common.IsolaattiBaseActivity
import com.isolaatti.databinding.ActivityImageChooserBinding
import com.isolaatti.images.image_chooser.presentation.ImageChooserViewModel
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class ImageChooserActivity : IsolaattiBaseActivity() {
private lateinit var binding: ActivityImageChooserBinding
private val viewModel: ImageChooserViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityImageChooserBinding.inflate(layoutInflater)
setContentView(binding.root)
setupObservers()
}
private fun setupObservers() {
viewModel.choose.observe(this) {
if(it == true && viewModel.selectedImage != null) {
viewModel.choose.value = false
val resultIntent = Intent().apply {
putExtra(OUTPUT_EXTRA_IMAGE, viewModel.selectedImage)
}
setResult(RESULT_OK, resultIntent)
finish()
return@observe
}
}
}
companion object {
const val INPUT_EXTRA = "requester"
const val OUTPUT_EXTRA_IMAGE = "output_image"
}
}

View File

@ -1,32 +0,0 @@
package com.isolaatti.images.image_chooser.ui
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.activity.result.contract.ActivityResultContract
import androidx.core.content.IntentCompat
import com.isolaatti.images.common.data.entity.ImageDraftEntity
import com.isolaatti.images.common.domain.entity.Image
class ImageChooserContract : ActivityResultContract<ImageChooserContract.Requester, ImageDraftEntity?>() {
enum class Requester {
UserPost, SquadPost
}
override fun createIntent(context: Context, input: Requester): Intent {
return Intent(context, ImageChooserActivity::class.java).apply {
putExtra(ImageChooserActivity.INPUT_EXTRA, input)
}
}
override fun parseResult(resultCode: Int, intent: Intent?): ImageDraftEntity? {
if(resultCode != Activity.RESULT_OK) { return null }
if(intent == null) {
return null
}
return IntentCompat.getSerializableExtra(intent, ImageChooserActivity.OUTPUT_EXTRA_IMAGE, ImageDraftEntity::class.java)
}
}

View File

@ -1,133 +0,0 @@
package com.isolaatti.images.image_chooser.ui
import android.app.Activity
import android.content.res.Resources
import android.net.Uri
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.core.content.FileProvider
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.GridLayoutManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.isolaatti.MyApplication
import com.isolaatti.R
import com.isolaatti.databinding.FragmentImageChooserMainBinding
import com.isolaatti.images.common.domain.entity.Image
import com.isolaatti.images.image_chooser.presentation.ImageChooserViewModel
import com.isolaatti.images.image_list.presentation.ImageListViewModel
import com.isolaatti.images.image_list.presentation.ImagesAdapter
import com.isolaatti.images.image_maker.ui.ImageMakerContract
import dagger.hilt.android.AndroidEntryPoint
import java.io.File
import java.util.Calendar
@AndroidEntryPoint
class ImageChooserMainFragment : Fragment() {
private lateinit var binding: FragmentImageChooserMainBinding
private val viewModel: ImageChooserViewModel by activityViewModels()
private val imagesListViewModel: ImageListViewModel by viewModels()
private lateinit var adapter: ImagesAdapter
private var cameraPhotoUri: Uri? = null
private val imageOnClick: (images: List<Image>, position: Int) -> Unit = { images, position ->
viewModel.selectedImage = images[position]
findNavController().navigate(ImageChooserMainFragmentDirections.actionImageChooserMainFragmentToImageChooserPreview())
}
private fun makePhotoUri(): Uri {
val cacheFile = File(requireContext().filesDir, "temp_picture_${Calendar.getInstance().timeInMillis}")
return FileProvider.getUriForFile(requireContext(), "${MyApplication.myApp.packageName}.provider", cacheFile)
}
private val imageMakerLauncher = registerForActivityResult(ImageMakerContract()) { image ->
image?.also {
viewModel.selectedImage = it
viewModel.choose.value = true
}
}
private val choosePictureLauncher = registerForActivityResult(ActivityResultContracts.PickVisualMedia()) {
if(it != null) {
imageMakerLauncher.launch(it)
}
}
private val takePhotoLauncher = registerForActivityResult(ActivityResultContracts.TakePicture()) {
if(it && cameraPhotoUri != null) {
imageMakerLauncher.launch(cameraPhotoUri)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
viewModel.getUserId()
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = FragmentImageChooserMainBinding.inflate(inflater)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
adapter = ImagesAdapter(
imageOnClick = imageOnClick,
itemWidth = Resources.getSystem().displayMetrics.widthPixels/3
)
binding.recycler.layoutManager =
GridLayoutManager(requireContext(), 3, GridLayoutManager.VERTICAL, false)
binding.recycler.adapter = adapter
setupObservers()
setupListeners()
}
private fun setupListeners() {
binding.toolbar.setNavigationOnClickListener {
requireActivity().run {
setResult(Activity.RESULT_CANCELED)
finish()
}
}
binding.takePhoto.setOnClickListener {
cameraPhotoUri = makePhotoUri()
takePhotoLauncher.launch(cameraPhotoUri)
}
binding.uploadPhoto.setOnClickListener {
choosePictureLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
}
}
private fun setupObservers() {
viewModel.userId.observe(viewLifecycleOwner) {
imagesListViewModel.userId = it
imagesListViewModel.loadNext()
Log.d("*****", "Se obtiene userId $it")
}
imagesListViewModel.liveList.observe(viewLifecycleOwner) { imageList ->
Log.d("*****", "Se obtiene lista $imageList")
adapter.submitList(imageList)
}
}
}

View File

@ -1,47 +0,0 @@
package com.isolaatti.images.image_chooser.ui
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
import coil.load
import com.isolaatti.databinding.FragmentImageChooserPreviewBinding
import com.isolaatti.images.image_chooser.presentation.ImageChooserViewModel
class ImageChooserPreview : Fragment() {
private lateinit var binding: FragmentImageChooserPreviewBinding
private val viewModel: ImageChooserViewModel by activityViewModels()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = FragmentImageChooserPreviewBinding.inflate(inflater)
return binding.root
}
private fun showLoading(show: Boolean) {
binding.chooseImageButton.visibility = if(show) View.INVISIBLE else View.VISIBLE
binding.progressBarLoading.visibility = if(show) View.VISIBLE else View.INVISIBLE
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.toolbar.setNavigationOnClickListener {
findNavController().popBackStack()
}
binding.image.load(viewModel.selectedImage?.imageUrl)
binding.chooseImageButton.setOnClickListener {
showLoading(true)
viewModel.choose.value = true
}
}
}

View File

@ -1,29 +0,0 @@
package com.isolaatti.images.image_list.presentation
import com.isolaatti.images.common.domain.entity.Image
interface ImageAdapterItem {
val image: Image?
val addImage: Boolean
companion object {
val AddImagePlaceholder: ImageAdapterItem = object: ImageAdapterItem {
override val image: Image?
get() = null
override val addImage: Boolean
get() = true
}
fun fromImage(image: Image): ImageAdapterItem {
return object: ImageAdapterItem {
override val addImage: Boolean
get() = false
override val image: Image?
get() = image
}
}
}
}

View File

@ -1,109 +0,0 @@
package com.isolaatti.images.image_list.presentation
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.isolaatti.auth.domain.AuthRepository
import com.isolaatti.images.common.domain.entity.Image
import com.isolaatti.images.common.domain.repository.ImagesRepository
import com.isolaatti.utils.Resource
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import javax.inject.Inject
import kotlin.properties.Delegates
@HiltViewModel
class ImageListViewModel @Inject constructor(private val imagesRepository: ImagesRepository, private val authRepository: AuthRepository) : ViewModel() {
enum class Event {
REMOVED_IMAGE, ADDED_IMAGE_BEGINNING
}
val liveList: MutableLiveData<List<Image>> = MutableLiveData(listOf())
val error: MutableLiveData<Resource.Error.ErrorType?> = MutableLiveData()
val loading: MutableLiveData<Boolean> = MutableLiveData()
val deleting: MutableLiveData<Boolean> = MutableLiveData()
var noMoreContent = false
var lastEvent: Event? = null
private var loadedFirstTime = false
var userId by Delegates.notNull<Int>()
val isUserItself: MutableLiveData<Boolean> = MutableLiveData(false)
private val list: List<Image> get() {
return liveList.value ?: listOf()
}
private val lastId: String? get() {
return list.lastOrNull()?.id
}
fun addImageAtTheBeginning(image: Image) {
liveList.value = listOf(image) + list
}
fun loadNext() {
viewModelScope.launch {
imagesRepository.getImagesOfUser(userId, lastId).onEach { resource ->
when(resource) {
is Resource.Error -> {
error.postValue(resource.errorType)
}
is Resource.Loading -> {
if(!loadedFirstTime) {
loading.postValue(true)
}
}
is Resource.Success -> {
loading.postValue(false)
noMoreContent = resource.data?.isEmpty() == true
loadedFirstTime = true
if(noMoreContent) {
return@onEach
}
liveList.postValue(list + (resource.data ?: listOf()))
}
}
}.flowOn(Dispatchers.IO).launchIn(this)
}
}
fun refresh() {
liveList.value = listOf()
loadNext()
}
fun removeImages(images: List<Image>) {
viewModelScope.launch {
imagesRepository.deleteImages(images).onEach {
when(it) {
is Resource.Error -> {
deleting.postValue(false)
}
is Resource.Loading -> {
deleting.postValue(true)
}
is Resource.Success -> {
deleting.postValue(false)
lastEvent = Event.REMOVED_IMAGE
liveList.postValue(list.filterNot { image -> images.contains(image) })
}
}
}.flowOn(Dispatchers.IO).launchIn(this)
}
}
fun getUserId() {
viewModelScope.launch {
authRepository.getUserId().onEach {
isUserItself.postValue(userId == it)
}.flowOn(Dispatchers.IO).launchIn(this)
}
}
}

View File

@ -1,123 +0,0 @@
package com.isolaatti.images.image_list.presentation
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.Adapter
import coil.load
import com.isolaatti.common.CoilImageLoader.imageLoader
import com.isolaatti.databinding.ImageItemBinding
import com.isolaatti.images.common.domain.entity.Image
class ImagesAdapter(
private val imageOnClick: ((images: List<Image>, position: Int) -> Unit),
private val itemWidth: Int,
private val onImageSelectedCountUpdate: ((count: Int) -> Unit)? = null,
private val onDeleteMode: ((enabled: Boolean) -> Unit)? = null,
private val onContentRequested: (() -> Unit)? = null) : ListAdapter<Image, ImagesAdapter.ImageViewHolder>(diffCallback){
companion object {
val diffCallback = object: DiffUtil.ItemCallback<Image>() {
override fun areItemsTheSame(oldItem: Image, newItem: Image): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: Image, newItem: Image): Boolean {
return oldItem == newItem
}
}
}
var deleteMode: Boolean = false
set(value) {
field = value
if(!value) {
currentList.forEach { it.delete = false }
}
notifyDataSetChanged()
}
inner class ImageViewHolder(val imageItemBinding: ImageItemBinding) : RecyclerView.ViewHolder(imageItemBinding.root)
fun getSelectedImages(): List<Image> {
return currentList.filter { it.delete }
}
override fun onCurrentListChanged(
previousList: MutableList<Image>,
currentList: MutableList<Image>
) {
super.onCurrentListChanged(previousList, currentList)
noMoreContent = (currentList.size - previousList.size) == 0
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ImageViewHolder {
val inflater = LayoutInflater.from(parent.context)
val binding = ImageItemBinding.inflate(inflater)
binding.root.layoutParams = LinearLayout.LayoutParams(itemWidth, itemWidth)
return ImageViewHolder(binding)
}
private var requestedNewContent = false
private var noMoreContent = false
/**
* Call this method when new content has been added on onLoadMore() callback
*/
fun newContentRequestFinished() {
requestedNewContent = false
}
override fun onBindViewHolder(holder: ImageViewHolder, position: Int) {
val image = getItem(position)
holder.imageItemBinding.image.load(image.reducedImageUrl, imageLoader)
if(deleteMode) {
holder.imageItemBinding.imageCheckbox.visibility = View.VISIBLE
holder.imageItemBinding.imageOverlay.visibility = View.VISIBLE
holder.imageItemBinding.root.setOnClickListener {
holder.imageItemBinding.imageCheckbox.isChecked = !holder.imageItemBinding.imageCheckbox.isChecked
}
holder.imageItemBinding.imageCheckbox.setOnCheckedChangeListener { buttonView, isChecked ->
image.delete = isChecked
onImageSelectedCountUpdate?.invoke(currentList.count { it.delete })
}
holder.imageItemBinding.imageCheckbox.isChecked = image.delete
holder.imageItemBinding.root.setOnLongClickListener(null)
} else {
holder.imageItemBinding.imageCheckbox.visibility = View.GONE
holder.imageItemBinding.imageCheckbox.isChecked = false
holder.imageItemBinding.imageOverlay.visibility = View.GONE
holder.imageItemBinding.root.setOnClickListener {
imageOnClick(currentList, position)
}
holder.imageItemBinding.imageCheckbox.setOnCheckedChangeListener(null)
holder.imageItemBinding.root.setOnLongClickListener {
image.delete = true
onDeleteMode?.invoke(true)
onImageSelectedCountUpdate?.invoke(currentList.count { it.delete })
true
}
}
val totalItems = currentList.size
if(totalItems > 0 && !requestedNewContent && !noMoreContent) {
Log.d("ImagesAdapter", "Total items: $totalItems")
if(position == totalItems - 1) {
requestedNewContent = true
onContentRequested?.invoke()
}
}
}
}

View File

@ -1,258 +0,0 @@
package com.isolaatti.images.image_list.ui
import android.app.Dialog
import android.content.res.Resources
import android.net.Uri
import android.os.Bundle
import android.view.ActionMode
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.widget.PopupMenu
import androidx.core.content.FileProvider
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.GridLayoutManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.isolaatti.MyApplication
import com.isolaatti.R
import com.isolaatti.common.ErrorMessageViewModel
import com.isolaatti.databinding.FragmentImagesBinding
import com.isolaatti.images.common.domain.entity.Image
import com.isolaatti.images.image_list.presentation.ImageAdapterItem
import com.isolaatti.images.image_list.presentation.ImageListViewModel
import com.isolaatti.images.image_list.presentation.ImagesAdapter
import com.isolaatti.images.image_maker.ui.ImageMakerContract
import com.isolaatti.images.picture_viewer.ui.PictureViewerActivity
import com.isolaatti.utils.Resource
import dagger.hilt.android.AndroidEntryPoint
import java.io.File
import java.util.Calendar
@AndroidEntryPoint
class ImagesFragment : Fragment() {
lateinit var viewBinding: FragmentImagesBinding
lateinit var adapter: ImagesAdapter
private val viewModel: ImageListViewModel by viewModels()
private val errorViewModel: ErrorMessageViewModel by activityViewModels()
private val arguments: ImagesFragmentArgs by navArgs()
private var cameraPhotoUri: Uri? = null
private val imageOnClick: (images: List<Image>, position: Int) -> Unit = { images, position ->
PictureViewerActivity.startActivityWithImages(requireContext(), images.toTypedArray(), position)
}
private val imageMakerLauncher = registerForActivityResult(ImageMakerContract()) { image ->
image?.also {
viewModel.lastEvent = ImageListViewModel.Event.ADDED_IMAGE_BEGINNING
viewModel.addImageAtTheBeginning(it)
}
}
private val choosePictureLauncher = registerForActivityResult(ActivityResultContracts.PickVisualMedia()) {
if(it != null) {
imageMakerLauncher.launch(it)
}
}
private val takePhotoLauncher = registerForActivityResult(ActivityResultContracts.TakePicture()) {
if(it && cameraPhotoUri != null) {
imageMakerLauncher.launch(cameraPhotoUri)
}
}
private fun makePhotoUri(): Uri {
val cacheFile = File(requireContext().filesDir, "temp_picture_${Calendar.getInstance().timeInMillis}")
return FileProvider.getUriForFile(requireContext(), "${MyApplication.myApp.packageName}.provider", cacheFile)
}
private var deletingImagesDialog: Dialog? = null
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
viewBinding = FragmentImagesBinding.inflate(inflater)
return viewBinding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
when(arguments.source) {
SOURCE_SQUAD -> {}
SOURCE_PROFILE -> {
viewModel.userId = arguments.sourceId.toInt()
viewModel.getUserId()
viewModel.loadNext()
}
}
setupAdapter()
setupObservers()
setupListeners()
viewBinding.topAppBar.inflateMenu(R.menu.images_menu)
}
private fun showDeleteDialog() {
val imagesToDelete = adapter.getSelectedImages()
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.delete)
.setMessage(getString(R.string.delete_images_dialog_message, imagesToDelete.size))
.setPositiveButton(R.string.yes_continue) { _, _ ->
viewModel.removeImages(imagesToDelete)
}
.setNegativeButton(R.string.no, null)
.show()
}
private var actionMode: ActionMode? = null
private val contextBarCallback: ActionMode.Callback = object: ActionMode.Callback {
override fun onCreateActionMode(mode: ActionMode?, menu: Menu?): Boolean {
requireActivity().menuInflater.inflate(R.menu.images_context_menu, menu)
return true
}
override fun onPrepareActionMode(mode: ActionMode?, menu: Menu?): Boolean {
return true
}
override fun onActionItemClicked(mode: ActionMode?, item: MenuItem?): Boolean {
return when(item?.itemId) {
R.id.delete_item -> {
showDeleteDialog()
true
}
else -> false
}
}
override fun onDestroyActionMode(mode: ActionMode?) {
adapter.deleteMode = false
}
}
private fun setupListeners() {
viewBinding.topAppBar.setNavigationOnClickListener {
findNavController().popBackStack()
}
viewBinding.topAppBar.setOnMenuItemClickListener {
when(it.itemId) {
else -> false
}
}
viewBinding.newPictureButton.setOnClickListener {
val popup = PopupMenu(requireContext(), it)
popup.menuInflater.inflate(R.menu.add_picture_menu, popup.menu)
popup.setOnMenuItemClickListener {
when(it.itemId) {
R.id.take_a_photo_menu_item -> {
cameraPhotoUri = makePhotoUri()
takePhotoLauncher.launch(cameraPhotoUri)
true
}
R.id.upload_a_picture_item -> {
choosePictureLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
true
}
else -> false
}
}
popup.show()
}
viewBinding.swipeToRefresh.setOnRefreshListener {
viewModel.refresh()
}
}
private fun setupAdapter() {
adapter = ImagesAdapter(
imageOnClick = imageOnClick,
itemWidth = Resources.getSystem().displayMetrics.widthPixels/3,
onImageSelectedCountUpdate = {
actionMode?.title = getString(R.string.selected_images_count, it)
actionMode?.menu?.findItem(R.id.delete_item)?.isEnabled = it > 0
},
onDeleteMode = {
if(viewModel.isUserItself.value == false) return@ImagesAdapter
adapter.deleteMode = it
actionMode = requireActivity().startActionMode(contextBarCallback)
},
onContentRequested = {
adapter.newContentRequestFinished()
viewModel.loadNext()
})
viewBinding.recyclerView.layoutManager =
GridLayoutManager(requireContext(), 3, GridLayoutManager.VERTICAL, false)
viewBinding.recyclerView.adapter = adapter
}
private fun setupObservers() {
viewModel.liveList.observe(viewLifecycleOwner) { list ->
if(viewModel.lastEvent == ImageListViewModel.Event.REMOVED_IMAGE || viewModel.lastEvent == ImageListViewModel.Event.ADDED_IMAGE_BEGINNING) {
actionMode?.finish()
}
viewBinding.noImagesCard.visibility = if(list.isEmpty()) View.VISIBLE else View.GONE
adapter.submitList(list)
}
viewModel.loading.observe(viewLifecycleOwner) {
viewBinding.progressBarLoading.visibility = if(it) {
View.VISIBLE
} else {
View.GONE
}
if(!it) {
viewBinding.swipeToRefresh.isRefreshing = false
}
}
viewModel.error.observe(viewLifecycleOwner) {
errorViewModel.error.value = it
}
viewModel.deleting.observe(viewLifecycleOwner) { deleting ->
if(deleting) {
deletingImagesDialog = MaterialAlertDialogBuilder(requireContext())
.setMessage(R.string.deleting_please_wait)
.setCancelable(false)
.show()
} else {
deletingImagesDialog?.dismiss()
deletingImagesDialog = null
}
}
viewModel.isUserItself.observe(viewLifecycleOwner) {
setupUiForUser(it)
}
}
private fun setupUiForUser(isUserItSelf: Boolean) {
viewBinding.newPictureButton.visibility = if(isUserItSelf) View.VISIBLE else View.GONE
}
companion object {
const val SOURCE_PROFILE = "source_profile"
const val SOURCE_SQUAD = "source_squads"
}
}

View File

@ -32,7 +32,6 @@ import com.isolaatti.common.options_bottom_sheet.domain.OptionClicked
import com.isolaatti.common.options_bottom_sheet.domain.Options
import com.isolaatti.common.options_bottom_sheet.presentation.BottomSheetPostOptionsViewModel
import com.isolaatti.common.options_bottom_sheet.ui.BottomSheetPostOptionsFragment
import com.isolaatti.images.image_chooser.ui.ImageChooserContract
import com.isolaatti.posting.link_creator.presentation.LinkCreatorViewModel
import com.isolaatti.posting.link_creator.ui.LinkCreatorFragment
import com.isolaatti.profile.ui.ProfileActivity
@ -122,14 +121,6 @@ class BottomSheetPostComments() : BottomSheetDialogFragment(), OnUserInteractedC
}
private val imageChooserLauncher = registerForActivityResult(ImageChooserContract()) { image ->
if(image != null) {
}
}
private fun setObservers() {
viewModel.comments.observe(viewLifecycleOwner, commentsObserver)
viewModel.noMoreContent.observe(viewLifecycleOwner, noMoreContentObserver)
@ -180,7 +171,7 @@ class BottomSheetPostComments() : BottomSheetDialogFragment(), OnUserInteractedC
}
private fun insertImage() {
imageChooserLauncher.launch(ImageChooserContract.Requester.UserPost)
}
private fun insertLink() {

View File

@ -7,9 +7,6 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.isolaatti.audio.common.domain.Audio
import com.isolaatti.audio.common.domain.Playable
import com.isolaatti.audio.common.domain.UploadAudioUC
import com.isolaatti.audio.drafts.domain.AudioDraft
import com.isolaatti.audio.drafts.domain.repository.AudioDraftsRepository
import com.isolaatti.posting.posts.data.remote.CreatePostDto
import com.isolaatti.posting.posts.data.remote.EditPostDto
import com.isolaatti.posting.posts.data.remote.EditPostDto.Companion.PRIVACY_ISOLAATTI
@ -34,9 +31,7 @@ import javax.inject.Inject
class CreatePostViewModel @Inject constructor(
private val makePost: MakePost,
private val editPost: EditPost,
private val loadPost: LoadSinglePost,
private val audioDraftsRepository: AudioDraftsRepository,
private val uploadAudioUC: UploadAudioUC
private val loadPost: LoadSinglePost
) : ViewModel() {
companion object {
@ -89,21 +84,7 @@ class CreatePostViewModel @Inject constructor(
viewModelScope.launch {
if(audioDraft != null) {
uploadAudioUC(audioDraft!!).onEach { upLoadAudioResource ->
Log.d(LOG_TAG, upLoadAudioResource.toString())
when(upLoadAudioResource) {
is Resource.Success -> {
Log.d(LOG_TAG, "uploadAudioResource: Success")
audioId = upLoadAudioResource.data?.id
sendDiscussion()
}
is Resource.Error -> {}
is Resource.Loading -> {
sendingPost.postValue(true)
}
}
}.flowOn(Dispatchers.IO).launchIn(this)
} else {
sendDiscussion()
}
@ -134,19 +115,7 @@ class CreatePostViewModel @Inject constructor(
viewModelScope.launch {
if(audioDraft != null) {
uploadAudioUC(audioDraft!!).onEach { upLoadAudioResource ->
when(upLoadAudioResource) {
is Resource.Success -> {
audioId = upLoadAudioResource.data?.id
sendEditDiscussion(postId)
}
is Resource.Error -> {}
is Resource.Loading -> {
sendingPost.postValue(true)
}
}
}.flowOn(Dispatchers.IO).launchIn(this)
} else {
sendEditDiscussion(postId)
}
@ -166,22 +135,6 @@ class CreatePostViewModel @Inject constructor(
}
}
// call this when user has recorded or selected a draft
fun putAudioDraft(draft: AudioDraft) {
viewModelScope.launch {
audioDraftsRepository.getAudioDraftById(draft.id).onEach { draft ->
when(draft) {
is Resource.Error -> {}
is Resource.Loading -> {}
is Resource.Success -> {
audioAttachment.postValue(draft.data)
this@CreatePostViewModel.audioDraft = draft.data!!.id
}
}
}.flowOn(Dispatchers.IO).launchIn(this)
}
}
// call this when user selects an existing audio (not a draft)
fun putAudio(audio: Audio) {

View File

@ -26,16 +26,11 @@ import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner
import com.isolaatti.MyApplication
import com.isolaatti.R
import com.isolaatti.audio.audio_selector.ui.AudioSelectorContract
import com.isolaatti.audio.common.domain.Audio
import com.isolaatti.audio.common.domain.Playable
import com.isolaatti.audio.drafts.domain.AudioDraft
import com.isolaatti.audio.player.AudioPlayerConnector
import com.isolaatti.audio.recorder.ui.AudioRecorderContract
import com.isolaatti.common.IsolaattiTheme
import com.isolaatti.databinding.FragmentMarkdownEditingBinding
import com.isolaatti.images.common.components.ImagesRow
import com.isolaatti.images.image_chooser.ui.ImageChooserContract
import com.isolaatti.posting.link_creator.presentation.LinkCreatorViewModel
import com.isolaatti.posting.link_creator.ui.LinkCreatorFragment
import com.isolaatti.posting.posts.presentation.CreatePostViewModel
@ -52,28 +47,6 @@ class PostEditingFragment : Fragment(){
private val viewModel: CreatePostViewModel by activityViewModels()
private val linkCreatorViewModel: LinkCreatorViewModel by viewModels()
private var audioPlayerConnector: AudioPlayerConnector? = null
private val audioRecorderLauncher = registerForActivityResult(AudioRecorderContract()) { audioDraft ->
if(audioDraft != null) {
viewModel.putAudioDraft(audioDraft)
binding.viewAnimator.displayedChild = 1
}
}
private val imageChooserLauncher = registerForActivityResult(ImageChooserContract()) { image ->
if(image != null) {
}
}
private val audioSelectorLauncher = registerForActivityResult(AudioSelectorContract()) {
}
private val choosePictureLauncher = registerForActivityResult(ActivityResultContracts.PickVisualMedia()) {
if(it != null) {
@ -94,32 +67,9 @@ class PostEditingFragment : Fragment(){
}
}
private val audioListener = object: AudioPlayerConnector.Listener {
override fun durationChanged(duration: Int, audio: Playable) {
binding.audioItem.audioProgress.max = duration
}
override fun onEnded(audio: Playable) {
binding.audioItem.audioProgress.progress = 0
}
override fun progressChanged(second: Int, audio: Playable) {
binding.audioItem.audioProgress.progress = second
}
override fun onPlaying(isPlaying: Boolean, audio: Playable) {
binding.audioItem.playButton.icon = AppCompatResources.getDrawable(requireContext(), if(isPlaying) R.drawable.baseline_pause_circle_24 else R.drawable.baseline_play_circle_24)
}
override fun isLoading(isLoading: Boolean, audio: Playable) {
binding.audioItem.audioProgress.isIndeterminate = isLoading
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
audioPlayerConnector = AudioPlayerConnector(requireContext())
audioPlayerConnector?.addListener(audioListener)
}
override fun onCreateView(
@ -135,7 +85,6 @@ class PostEditingFragment : Fragment(){
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewLifecycleOwner.lifecycle.addObserver(audioPlayerConnector!!)
viewLifecycleOwner.lifecycle.addObserver(object: LifecycleEventObserver {
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
@ -146,7 +95,7 @@ class PostEditingFragment : Fragment(){
setupListeners()
setupObservers()
binding.imagesRowCompose.apply {
binding.composeView.apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
val pictures by viewModel.photos.collectAsState()
@ -156,7 +105,9 @@ class PostEditingFragment : Fragment(){
images = pictures,
deletable = true,
addable = true,
onClick = {},
onClick = {
},
onDeleteClick = {
viewModel.removePicture(it)
},
@ -175,7 +126,6 @@ class PostEditingFragment : Fragment(){
override fun onDestroyView() {
super.onDestroyView()
viewLifecycleOwner.lifecycle.removeObserver(audioPlayerConnector!!)
}
private fun setupListeners() {
@ -186,34 +136,6 @@ class PostEditingFragment : Fragment(){
viewModel.validation.postValue(!text.isNullOrEmpty())
viewModel.content = text.toString()
}
binding.addAudioButton.setOnClickListener {
val popupMenu = PopupMenu(requireContext(), it)
popupMenu.inflate(R.menu.attach_audio_menu)
popupMenu.setOnMenuItemClickListener { menuItem ->
when(menuItem.itemId){
R.id.record_new_audio_item -> {
audioRecorderLauncher.launch(null)
true
}
R.id.select_from_audios -> {
audioSelectorLauncher.launch(AudioSelectorContract.SelectorConfig(false, null))
true
}
else -> false
}
}
popupMenu.show()
}
binding.removeAudio.setOnClickListener {
binding.viewAnimator.displayedChild = 0
viewModel.removeAudio()
}
}
private fun setupObservers(){
@ -229,30 +151,7 @@ class PostEditingFragment : Fragment(){
}
viewModel.audioAttachment.observe(viewLifecycleOwner) { playable ->
if(playable != null) {
audioPlayerConnector?.stopPlayback()
binding.audioItem.playButton.setOnClickListener {
audioPlayerConnector?.playPauseAudio(playable)
}
when(playable) {
is Audio -> {
binding.audioItem.textViewDescription.text = playable.name
}
is AudioDraft -> {
binding.audioItem.textViewDescription.text = playable.name
}
}
}
}
}
private fun insertImage() {
imageChooserLauncher.launch(ImageChooserContract.Requester.UserPost)
}
private fun insertLink() {
LinkCreatorFragment().show(childFragmentManager, null)
}
}

View File

@ -15,9 +15,6 @@ import androidx.recyclerview.widget.LinearLayoutManager
import com.isolaatti.BuildConfig
import com.isolaatti.R
import com.isolaatti.audio.common.domain.Audio
import com.isolaatti.audio.common.domain.Playable
import com.isolaatti.audio.player.AudioPlayerConnector
import com.isolaatti.common.CoilImageLoader
import com.isolaatti.common.Dialogs
import com.isolaatti.common.ErrorMessageViewModel
import com.isolaatti.common.OnUserInteractedWithPostCallback
@ -39,12 +36,6 @@ import com.isolaatti.profile.ui.ProfileActivity
import com.isolaatti.reports.data.ContentType
import com.isolaatti.reports.ui.NewReportBottomSheetDialogFragment
import dagger.hilt.android.AndroidEntryPoint
import io.noties.markwon.AbstractMarkwonPlugin
import io.noties.markwon.Markwon
import io.noties.markwon.MarkwonConfiguration
import io.noties.markwon.image.coil.CoilImagesPlugin
import io.noties.markwon.image.destination.ImageDestinationProcessorRelativeToAbsolute
import io.noties.markwon.linkify.LinkifyPlugin
@AndroidEntryPoint
class PostListingFragment : Fragment(), OnUserInteractedWithPostCallback {
@ -60,35 +51,6 @@ class PostListingFragment : Fragment(), OnUserInteractedWithPostCallback {
private val viewModel: PostListingViewModel by viewModels()
val optionsViewModel: BottomSheetPostOptionsViewModel by activityViewModels()
private lateinit var adapter: PostsRecyclerViewAdapter
private lateinit var audioPlayerConnector: AudioPlayerConnector
private val audioPlayerConnectorListener = object: AudioPlayerConnector.Listener {
override fun onPlaying(isPlaying: Boolean, audio: Playable) {
if(audio is Audio)
adapter.setIsPlaying(isPlaying, audio)
}
override fun isLoading(isLoading: Boolean, audio: Playable) {
if(audio is Audio)
adapter.setIsLoading(isLoading, audio)
}
override fun progressChanged(second: Int, audio: Playable) {
if(audio is Audio)
adapter.setProgress(second, audio)
}
override fun durationChanged(duration: Int, audio: Playable) {
if(audio is Audio)
adapter.setDuration(duration, audio)
}
override fun onEnded(audio: Playable) {
if(audio is Audio)
adapter.setEnded(audio)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -109,22 +71,6 @@ class PostListingFragment : Fragment(), OnUserInteractedWithPostCallback {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
audioPlayerConnector = AudioPlayerConnector(requireContext())
audioPlayerConnector.addListener(audioPlayerConnectorListener)
viewLifecycleOwner.lifecycle.addObserver(audioPlayerConnector)
val markwon = Markwon.builder(requireContext())
.usePlugin(object: AbstractMarkwonPlugin() {
override fun configureConfiguration(builder: MarkwonConfiguration.Builder) {
builder
.imageDestinationProcessor(
ImageDestinationProcessorRelativeToAbsolute
.create(BuildConfig.backend))
}
})
.usePlugin(CoilImagesPlugin.create(requireContext(), CoilImageLoader.imageLoader))
.usePlugin(LinkifyPlugin.create())
.build()
adapter = PostsRecyclerViewAdapter(this)
viewBinding.feedRecyclerView.adapter = adapter
viewBinding.feedRecyclerView.setItemViewCacheSize(7)
@ -177,7 +123,6 @@ class PostListingFragment : Fragment(), OnUserInteractedWithPostCallback {
}
override fun onPlay(audio: Audio) {
audioPlayerConnector.playPauseAudio(audio)
}
override fun onMoreInfo(postId: Long) {

View File

@ -22,11 +22,8 @@ import coil.load
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.isolaatti.BuildConfig
import com.isolaatti.R
import com.isolaatti.audio.audio_selector.ui.AudioSelectorContract
import com.isolaatti.audio.audios_list.ui.AudiosFragment
import com.isolaatti.audio.common.domain.Audio
import com.isolaatti.audio.common.domain.Playable
import com.isolaatti.audio.player.AudioPlayerConnector
import com.isolaatti.common.CoilImageLoader.imageLoader
import com.isolaatti.common.Dialogs
import com.isolaatti.common.ErrorMessageViewModel
@ -39,8 +36,6 @@ import com.isolaatti.databinding.FragmentDiscussionsBinding
import com.isolaatti.followers.domain.FollowingState
import com.isolaatti.hashtags.ui.HashtagsPostsActivity
import com.isolaatti.images.common.domain.entity.Image
import com.isolaatti.images.image_chooser.ui.ImageChooserContract
import com.isolaatti.images.image_list.ui.ImagesFragment
import com.isolaatti.images.picture_viewer.ui.PictureViewerActivity
import com.isolaatti.posting.comments.ui.BottomSheetPostComments
import com.isolaatti.posting.posts.domain.entity.Post
@ -78,7 +73,6 @@ class ProfileMainFragment : Fragment() {
private var audioDescriptionAudio: Audio? = null
private lateinit var audioPlayerConnector: AudioPlayerConnector
// collapsing bar
private var title = ""
@ -94,12 +88,6 @@ class ProfileMainFragment : Fragment() {
viewModel.onPostAddedAtTheBeginning(it)
}
private val chooseImageLauncher = registerForActivityResult(ImageChooserContract()) { image ->
// here change profile picture
if(image != null) {
}
}
private val editDiscussion = registerForActivityResult(EditPostContract()) {
if(it != null) {
@ -113,64 +101,6 @@ class ProfileMainFragment : Fragment() {
}
}
private val audioSelectorLauncher = registerForActivityResult(AudioSelectorContract()) {
}
private val audioPlayerConnectorListener = object: AudioPlayerConnector.Listener {
override fun onPlaying(isPlaying: Boolean, audio: Playable) {
Log.d(LOG_TAG, "onPlaying() isPlaying: $isPlaying: audio $audio")
if(audio == audioDescriptionAudio) {
viewBinding.playButton.icon =
AppCompatResources.getDrawable(
requireContext(),
if(isPlaying) R.drawable.baseline_pause_circle_24 else R.drawable.baseline_play_circle_24
)
}
if(audio is Audio)
postsAdapter.setIsPlaying(isPlaying, audio)
}
override fun isLoading(isLoading: Boolean, audio: Playable) {
if(audio == audioDescriptionAudio) {
viewBinding.playButton.isEnabled = !isLoading
viewBinding.audioProgress.isIndeterminate = isLoading
}
if(audio is Audio)
postsAdapter.setIsLoading(isLoading, audio)
}
override fun progressChanged(second: Int, audio: Playable) {
if(audio == audioDescriptionAudio) {
viewBinding.audioProgress.setProgress(second, true)
}
if(audio is Audio)
postsAdapter.setProgress(second, audio)
}
override fun durationChanged(duration: Int, audio: Playable) {
if(audio == audioDescriptionAudio) {
viewBinding.audioProgress.max = duration
}
if(audio is Audio)
postsAdapter.setDuration(duration, audio)
}
override fun onEnded(audio: Playable) {
if(audio == audioDescriptionAudio) {
viewBinding.audioProgress.progress = 0
}
if(audio is Audio)
postsAdapter.setEnded(audio)
}
}
private val profileObserver = Observer<UserProfile> { profile ->
viewBinding.profileImageView.load(UrlGen.userProfileImage(profile.userId, invalidateCache = true), imageLoader)
@ -243,7 +173,7 @@ class ProfileMainFragment : Fragment() {
val profile = optionClicked.payload as? UserProfile
when(optionClicked.optionId) {
Options.Option.OPTION_PROFILE_PHOTO_CHANGE_PHOTO -> {
chooseImageLauncher.launch(ImageChooserContract.Requester.UserPost)
}
Options.Option.OPTION_PROFILE_PHOTO_REMOVE_PHOTO -> {
showRemoveProfileImageDialog()
@ -287,7 +217,6 @@ class ProfileMainFragment : Fragment() {
}
Options.PROFILE_DESCRIPTION_OPTIONS -> {
optionsViewModel.handle()
audioSelectorLauncher.launch(AudioSelectorContract.SelectorConfig(false, null))
}
}
@ -394,7 +323,7 @@ class ProfileMainFragment : Fragment() {
}
viewBinding.playButton.setOnClickListener {
audioDescriptionAudio?.let { audio ->
audioPlayerConnector.playPauseAudio(audio)
}
}
@ -499,7 +428,7 @@ class ProfileMainFragment : Fragment() {
}
override fun onPlay(audio: Audio) {
audioPlayerConnector.playPauseAudio(audio)
}
override fun onLoadMore() {
@ -543,12 +472,6 @@ class ProfileMainFragment : Fragment() {
setObservers()
setupCollapsingBar()
audioPlayerConnector = AudioPlayerConnector(requireContext())
audioPlayerConnector.addListener(audioPlayerConnectorListener)
viewLifecycleOwner.lifecycle.addObserver(audioPlayerConnector)
viewLifecycleOwner.lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {

View File

@ -16,39 +16,6 @@
app:navigationIcon="@drawable/baseline_close_24"
app:menu="@menu/audio_recorder_menu"/>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/input_draft_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="8dp"
app:layout_constraintTop_toBottomOf="@id/toolbar"
android:hint="Name"
style="@style/Widget.MaterialComponents.TextInputLayout.FilledBox">
<com.google.android.material.textfield.TextInputEditText
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.card.MaterialCardView
android:id="@+id/materialCardView2"
style="?attr/materialCardViewElevatedStyle"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_margin="24dp"
app:layout_constraintBottom_toTopOf="@id/time"
app:layout_constraintTop_toBottomOf="@id/input_draft_name"
tools:layout_editor_absoluteX="24dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.card.MaterialCardView>
<TextView
android:id="@+id/time"

View File

@ -7,15 +7,15 @@
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
android:layout_height="match_parent"
android:fitsSystemWindows="true">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/appBarLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:fitsSystemWindows="true">
app:layout_constraintEnd_toEndOf="parent">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
@ -64,6 +64,7 @@
android:layout_marginBottom="16dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:srcCompat="@drawable/baseline_send_24" />
app:srcCompat="@drawable/baseline_send_24"
android:fitsSystemWindows="true"/>
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@ -25,55 +25,9 @@
</com.google.android.material.textfield.TextInputLayout>
<androidx.compose.ui.platform.ComposeView
android:id="@+id/images_row_compose"
android:id="@+id/compose_view"
android:layout_width="match_parent"
android:layout_height="160dp"/>
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginVertical="30dp"
android:layout_marginHorizontal="24dp"
style="?attr/materialCardViewFilledStyle">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_margin="16dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/audio_attachment"/>
<ViewAnimator
android:id="@+id/view_animator"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp">
<com.google.android.material.button.MaterialButton
android:id="@+id/add_audio_button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:text="Attach audio" />
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<include android:id="@+id/audio_item" layout="@layout/audio_attachment"/>
<com.google.android.material.button.MaterialButton
android:id="@+id/remove_audio"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toBottomOf="@id/audio_item"
android:layout_marginTop="4dp"
style="?attr/materialIconButtonFilledTonalStyle"
app:icon="@drawable/baseline_close_24"
android:text="@string/remove"/>
</androidx.constraintlayout.widget.ConstraintLayout>
</ViewAnimator>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
</LinearLayout>
</androidx.core.widget.NestedScrollView>

View File

@ -8,12 +8,6 @@
android:id="@+id/mainFragment"
android:name="com.isolaatti.profile.ui.ProfileMainFragment"
android:label="DiscussionsFragment" >
<action
android:id="@+id/action_discussionsFragment_to_audiosFragment"
app:destination="@id/audiosFragment" />
<action
android:id="@+id/action_discussionsFragment_to_imagesFragment"
app:destination="@id/imagesFragment" />
<action
android:id="@+id/action_discussionsFragment_to_blockProfileFragment"
app:destination="@id/blockProfileFragment" />
@ -33,27 +27,6 @@
app:destination="@id/qrCodeFragment"
app:popExitAnim="@android:anim/slide_out_right" />
</fragment>
<fragment
android:id="@+id/audiosFragment"
android:name="com.isolaatti.audio.audios_list.ui.AudiosFragment"
android:label="AudiosFragment" >
<argument
android:name="source"
app:argType="string" />
<argument android:name="sourceId"
app:argType="string" />
</fragment>
<fragment
android:id="@+id/imagesFragment"
android:name="com.isolaatti.images.image_list.ui.ImagesFragment"
android:label="ImagesFragment" >
<argument
android:name="source"
app:argType="string" />
<argument
android:name="sourceId"
app:argType="string" />
</fragment>
<fragment
android:id="@+id/blockProfileFragment"
android:name="com.isolaatti.profile.ui.BlockProfileFragment"

View File

@ -14,10 +14,12 @@ buildscript {
}
}
plugins {
id 'com.android.application' version '8.3.1' apply false
id 'com.android.library' version '8.3.1' apply false
id 'org.jetbrains.kotlin.android' version '1.9.0' apply false
id 'com.google.dagger.hilt.android' version '2.47' apply false
id 'com.google.gms.google-services' version '4.4.0' apply false
id 'com.google.firebase.crashlytics' version '2.9.9' apply false
id 'com.android.application' version '8.7.3' apply false
id 'com.android.library' version '8.7.3' apply false
id 'org.jetbrains.kotlin.android' version '2.1.0' apply false
id 'com.google.dagger.hilt.android' version '2.53.1' apply false
id 'com.google.gms.google-services' version '4.4.2' apply false
id 'com.google.firebase.crashlytics' version '3.0.2' apply false
id 'org.jetbrains.kotlin.kapt' version '2.1.0' apply false
id 'com.google.devtools.ksp' version '2.1.0-1.0.29' apply false
}

View File

@ -1,6 +1,6 @@
#Thu Jul 27 21:16:21 CST 2023
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists