Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions .github/workflows/update-screenshots.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
name: Update App Screenshots

on:
push:
branches: [ main ]
paths:
- 'app/src/main/res/layout/**'
- 'app/src/main/res/drawable/**'
- 'app/src/main/res/values/styles.xml'
- 'app/src/main/res/values/themes.xml'
- 'app/src/main/res/values/colors.xml'
- 'app/src/main/kotlin/com/scrolless/app/features/**/*.kt'
- 'libraries/components/src/main/**'
pull_request:
branches: [ main ]
paths:
- 'app/src/main/res/layout/**'
- 'app/src/main/res/drawable/**'
- 'app/src/main/res/values/styles.xml'
- 'app/src/main/res/values/themes.xml'
- 'app/src/main/res/values/colors.xml'
- 'app/src/main/kotlin/com/scrolless/app/features/**/*.kt'
- 'libraries/components/src/main/**'
workflow_dispatch: # Allows manual triggering

concurrency:
group: screenshots-${{ github.ref }}
cancel-in-progress: true

jobs:
screenshots:
name: Generate App Screenshots
runs-on: ubuntu-latest

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Set up JDK 17
uses: actions/setup-java@v4
with:
distribution: 'zulu'
java-version: '17'

- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
with:
cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}

- name: Run all screenshot tests (Roborazzi)
id: screenshotsverify
continue-on-error: true
run: ./gradlew verifyRoborazziDebug

- name: Prevent pushing new screenshots if this is a fork
id: checkfork_screenshots
continue-on-error: false
if: steps.screenshotsverify.outcome == 'failure' && github.event.pull_request.head.repo.full_name != github.repository
run: |
echo "::error::Screenshot tests failed, please create a PR in your fork first."
echo "Your fork's CI will take screenshots for your fork."
exit 1

# Runs if previous job failed
- name: Generate new screenshots if verification failed and it's a PR
id: screenshotsrecord
if: steps.screenshotsverify.outcome == 'failure' && github.event_name == 'pull_request'
run: |
./gradlew recordRoborazziDebug

- name: Upload screenshot results (PNG)
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: screenshot-test-results
path: '**/build/outputs/roborazzi/*.png'

- name: Copy screenshots to art folder
run: |
mkdir -p art/screenshots
cp -R **/build/outputs/roborazzi/*.png art/screenshots/ || echo "No screenshots found"

- name: Update main screenshots
run: |
cp art/screenshots/home_screen.png art/scrollessapp.png || echo "Main screenshot not found"
# Add more specific screenshot copies if needed

- name: Push new screenshots if available
uses: stefanzweifel/git-auto-commit-action@v5
with:
file_pattern: 'art/**/*.png'
disable_globbing: false
commit_message: "🤖 Updates app screenshots [skip ci]"
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,14 +46,15 @@ Your feedback is very important for us - we're actively working to improve the a
Set intervals for usage and breaks, allowing controlled social media access throughout the day.

- **Usage Tracking** (Upcoming Feature)
Keep track of how much time youve spent on Instagram Reels, TikTok, YouTube Shorts, and Facebook Reels.
Keep track of how much time you've spent on Instagram Reels, TikTok, YouTube Shorts, and Facebook Reels.

## Screenshots

| Main Screen | Blocking in Action |
|-------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| <img src="art/scrollessapp.png" alt="Scrolless app" width="250"> <br> UI in progress | <img src="art/block_in_action.gif" alt="Scrolless app" width="250"> <br> As soon as the user enters the reel view, the app automatically blocks it by pressing back |

> **Note**: Screenshots are automatically updated by our CI pipeline whenever significant UI changes are made to the application.

# Architecture

Expand Down
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ dependencies {

// Test dependencies
testImplementation(libs.test.junit)
testImplementation(libs.test.robolectric)
androidTestImplementation(libs.test.androidx.junit)
androidTestImplementation(libs.test.androidx.espresso.core)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
/*
* Copyright (C) 2025, Scrolless
* All rights reserved.
*/
package com.scrolless.app.features.dialogs

import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.provider.Settings
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.animation.AccelerateDecelerateInterpolator
import android.view.animation.AnimationUtils
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import com.scrolless.app.R
import com.scrolless.app.databinding.DialogAccessibilityExplainerBinding
import com.scrolless.app.services.ScrollessBlockAccessibilityService
import com.scrolless.framework.extensions.isAccessibilityServiceEnabled
import com.scrolless.framework.extensions.showToast

/**
* A dialog that explains to the user why accessibility permissions are needed
* and guides them through the process of enabling them.
*/
class AccessibilityExplainerDialog : BottomSheetDialogFragment() {

private var _binding: DialogAccessibilityExplainerBinding? = null
private val binding get() = _binding!!

// Keep track of animation handlers to prevent memory leaks
private val handlers = mutableListOf<Handler>()

// Flag to ensure animations only run once
private var animationsApplied = false

companion object {
const val TAG = "AccessibilityExplainerDialog"
private const val GITHUB_URL = "https://github.com/duartebarbosadev/scrolless"

fun newInstance(): AccessibilityExplainerDialog = AccessibilityExplainerDialog()
}

override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = DialogAccessibilityExplainerBinding.inflate(inflater, container, false)
return binding.root
}

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupUI()

// Only run animations if they haven't been applied
// and there's no saved instance state (first creation)
if (!animationsApplied && savedInstanceState == null) {
setupAnimations()
animationsApplied = true
} else {
// If we're restoring state, make everything visible without animation
makeAllElementsVisible()
}
}

private fun makeAllElementsVisible() {
binding.apply {
cardContent.alpha = 1f
cardContent.translationY = 0f
step1Container.alpha = 1f
step2Container.alpha = 1f
step3Container.alpha = 1f
privacyContainer.alpha = 1f
tvOpenSourceNote.alpha = 1f
btnProceed.alpha = 1f
btnNotNow.alpha = 1f
}
}

private fun setupUI() {
// Setup buttons
binding.btnProceed.setOnClickListener {
openAccessibilitySettings()
dismiss()
}

binding.btnNotNow.setOnClickListener {
dismiss()
}

// Add GitHub link
binding.tvOpenSourceNote.setOnClickListener {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(GITHUB_URL))
startActivity(intent)
}
}

private fun setupAnimations() {
// Animate the icon container with elevation and pulse
val pulseAnimation = AnimationUtils.loadAnimation(requireContext(), R.anim.pulse_animation)
binding.iconContainer.startAnimation(pulseAnimation)
binding.imgLogo.startAnimation(pulseAnimation)

// Animate content card with slight float animation
binding.cardContent.apply {
alpha = 0f
translationY = 50f
animate()
.alpha(1f)
.translationY(0f)
.setDuration(400)
.setInterpolator(AccelerateDecelerateInterpolator())
.start()
}

// Animate steps with a staggered entry
animateWithDelay(binding.step1Container, 200)
animateWithDelay(binding.step2Container, 400)
animateWithDelay(binding.step3Container, 600)
animateWithDelay(binding.privacyContainer, 800)
animateWithDelay(binding.tvOpenSourceNote, 900)

// Button animations
animateWithDelay(binding.btnProceed, 1000)
animateWithDelay(binding.btnNotNow, 1100)
}

private fun animateWithDelay(view: View, delay: Long) {
view.alpha = 0f
val handler = Handler(Looper.getMainLooper())
handlers.add(handler)

handler.postDelayed({
if (isAdded && view.isAttachedToWindow) {
val fadeInAnimation = AnimationUtils.loadAnimation(requireContext(), R.anim.fade_in_slow)
view.alpha = 1f
view.startAnimation(fadeInAnimation)
}
}, delay)
}

override fun onResume() {
super.onResume()
// Check if the service was enabled while in settings
if (requireContext().isAccessibilityServiceEnabled(ScrollessBlockAccessibilityService::class.java)) {
dismiss()
}
}

private fun openAccessibilitySettings() {
val intent = Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)
requireContext().startActivity(intent)
requireContext().showToast(getString(R.string.accessibility_settings_toast))
}

override fun onDestroyView() {
// Clear all animation handlers to prevent memory leaks
handlers.forEach { it.removeCallbacksAndMessages(null) }
handlers.clear()

super.onDestroyView()
_binding = null
}

override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
// Save that animations have been applied
outState.putBoolean("animationsApplied", true)
}

override fun onViewStateRestored(savedInstanceState: Bundle?) {
super.onViewStateRestored(savedInstanceState)
if (savedInstanceState != null) {
animationsApplied = savedInstanceState.getBoolean("animationsApplied", false)
}
}
}
Loading