[READ ONLY MIRROR] Open Source TikTok alternative built on AT Protocol github.com/sprksocial/client
flutter atproto video dart
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(android): adaptive icon

+182 -93
+2
android/app/.gradle/config.properties
··· 1 + #Wed Jan 14 14:15:05 EST 2026 2 + java.home=/Applications/Android Studio.app/Contents/jbr/Contents/Home
+8
android/app/local.properties
··· 1 + ## This file must *NOT* be checked into Version Control Systems, 2 + # as it contains information specific to your local configuration. 3 + # 4 + # Location of the SDK. This is only used by Gradle. 5 + # For customization when using a Version Control System, please read the 6 + # header note. 7 + #Wed Jan 14 14:15:05 EST 2026 8 + sdk.dir=/Users/knotbin/Library/Android/sdk
+2 -1
android/app/src/main/AndroidManifest.xml
··· 7 7 <application 8 8 android:label="Spark" 9 9 android:name="${applicationName}" 10 - android:icon="@mipmap/ic_launcher"> 10 + android:icon="@mipmap/ic_launcher" 11 + android:roundIcon="@mipmap/ic_launcher_round"> 11 12 <activity 12 13 android:name=".MainActivity" 13 14 android:exported="true"
android/app/src/main/ic_launcher-playstore.png

This is a binary file and will not be displayed.

android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png

This is a binary file and will not be displayed.

android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png

This is a binary file and will not be displayed.

+1 -1
android/app/src/main/res/drawable-night-v21/launch_background.xml
··· 1 1 <?xml version="1.0" encoding="utf-8"?> 2 2 <layer-list xmlns:android="http://schemas.android.com/apk/res/android"> 3 3 <item> 4 - <bitmap android:gravity="fill" android:src="@drawable/background"/> 4 + <color android:gravity="fill" android:color="@color/ic_launcher_background"/> 5 5 </item> 6 6 </layer-list>
+1 -1
android/app/src/main/res/drawable-night/launch_background.xml
··· 1 1 <?xml version="1.0" encoding="utf-8"?> 2 2 <layer-list xmlns:android="http://schemas.android.com/apk/res/android"> 3 3 <item> 4 - <bitmap android:gravity="fill" android:src="@drawable/background"/> 4 + <color android:gravity="fill" android:color="@color/ic_launcher_background"/> 5 5 </item> 6 6 </layer-list>
+1 -1
android/app/src/main/res/drawable-v21/launch_background.xml
··· 1 1 <?xml version="1.0" encoding="utf-8"?> 2 2 <layer-list xmlns:android="http://schemas.android.com/apk/res/android"> 3 3 <item> 4 - <bitmap android:gravity="fill" android:src="@drawable/background"/> 4 + <color android:gravity="fill" android:color="@color/ic_launcher_background"/> 5 5 </item> 6 6 </layer-list>
android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png

This is a binary file and will not be displayed.

android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png

This is a binary file and will not be displayed.

android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png

This is a binary file and will not be displayed.

+40 -24
android/app/src/main/res/drawable/ic_launcher_foreground.xml
··· 1 - <?xml version="1.0" encoding="utf-8"?> 2 1 <vector xmlns:android="http://schemas.android.com/apk/res/android" 3 - xmlns:aapt="http://schemas.android.com/aapt" 4 2 android:width="108dp" 5 3 android:height="108dp" 6 - android:viewportWidth="108" 7 - android:viewportHeight="108"> 8 - <path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z"> 9 - <aapt:attr name="android:fillColor"> 10 - <gradient 11 - android:endX="85.84757" 12 - android:endY="92.4963" 13 - android:startX="42.9492" 14 - android:startY="49.59793" 15 - android:type="linear"> 16 - <item 17 - android:color="#44000000" 18 - android:offset="0.0" /> 19 - <item 20 - android:color="#00000000" 21 - android:offset="1.0" /> 22 - </gradient> 23 - </aapt:attr> 24 - </path> 4 + android:viewportWidth="594" 5 + android:viewportHeight="596"> 6 + <group android:scaleX="0.39798996" 7 + android:scaleY="0.4" 8 + android:translateX="178.79698" 9 + android:translateY="178.8"> 25 10 <path 11 + android:pathData="M554.34,144.95C571.49,175.46 560.66,214.08 530.15,231.23C525.68,233.74 520.93,235.7 515.98,237.07L297.11,297.54L297.11,297.54L458.18,132.35C481.53,108.4 519.88,107.91 543.83,131.27C547.97,135.3 551.51,139.91 554.34,144.95Z" 12 + android:strokeWidth="1" 26 13 android:fillColor="#FFFFFF" 27 14 android:fillType="nonZero" 28 - android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z" 15 + android:strokeColor="#00000000"/> 16 + <path 17 + android:pathData="M554.03,448.98C537.44,478.27 500.23,488.57 470.94,471.97C465.96,469.15 461.4,465.63 457.4,461.54L297.11,297.54L297.11,297.54L515.78,357.12C549.35,366.27 569.15,400.91 560,434.48C558.62,439.55 556.62,444.42 554.03,448.98Z" 29 18 android:strokeWidth="1" 30 - android:strokeColor="#00000000" /> 31 - </vector> 19 + android:fillColor="#FFFFFF" 20 + android:fillType="nonZero" 21 + android:strokeColor="#00000000"/> 22 + <path 23 + android:pathData="M39.77,451.29C22.51,420.45 33.52,381.46 64.36,364.2C68.81,361.71 73.54,359.76 78.45,358.39L297.11,297.54L297.11,297.54L136.18,463.83C112.91,487.87 74.55,488.5 50.51,465.23C46.27,461.13 42.65,456.43 39.77,451.29Z" 24 + android:strokeWidth="1" 25 + android:fillColor="#FFFFFF" 26 + android:fillType="nonZero" 27 + android:strokeColor="#00000000"/> 28 + <path 29 + android:pathData="M40.11,144.16C56.54,114.95 93.53,104.59 122.74,121.02C127.85,123.89 132.52,127.49 136.6,131.71L297.11,297.54L297.11,297.54L78.62,236.86C44.69,227.44 24.82,192.29 34.24,158.36C35.62,153.41 37.59,148.64 40.11,144.16Z" 30 + android:strokeWidth="1" 31 + android:fillColor="#FFFFFF" 32 + android:fillType="nonZero" 33 + android:strokeColor="#00000000"/> 34 + <path 35 + android:pathData="M297.11,595.08C261.8,595.08 233.18,566.46 233.18,531.15C233.18,525.24 234,519.35 235.62,513.66L297.11,297.54L297.11,297.54L358.59,513.66C368.25,547.62 348.56,582.98 314.6,592.64C308.91,594.26 303.02,595.08 297.11,595.08Z" 36 + android:strokeWidth="1" 37 + android:fillColor="#FFFFFF" 38 + android:fillType="nonZero" 39 + android:strokeColor="#00000000"/> 40 + <path 41 + android:pathData="M297.11,0C332.41,0 361.03,28.62 361.03,63.93C361.03,69.84 360.21,75.73 358.59,81.42L297.11,297.54L297.11,297.54L235.62,81.42C225.96,47.46 245.65,12.1 279.61,2.44C285.3,0.82 291.19,0 297.11,0Z" 42 + android:strokeWidth="1" 43 + android:fillColor="#FFFFFF" 44 + android:fillType="nonZero" 45 + android:strokeColor="#00000000"/> 46 + </group> 47 + </vector>
+1 -1
android/app/src/main/res/drawable/launch_background.xml
··· 1 1 <?xml version="1.0" encoding="utf-8"?> 2 2 <layer-list xmlns:android="http://schemas.android.com/apk/res/android"> 3 3 <item> 4 - <bitmap android:gravity="fill" android:src="@drawable/background"/> 4 + <color android:gravity="fill" android:color="@color/ic_launcher_background"/> 5 5 </item> 6 6 </layer-list>
+4 -7
android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
··· 1 1 <?xml version="1.0" encoding="utf-8"?> 2 2 <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> 3 - <background android:drawable="@color/ic_launcher_background"/> 4 - <foreground> 5 - <inset 6 - android:drawable="@drawable/sprk_icon" 7 - android:inset="27%" /> 8 - </foreground> 9 - </adaptive-icon> 3 + <background android:drawable="@color/ic_launcher_background"/> 4 + <foreground android:drawable="@drawable/ic_launcher_foreground"/> 5 + <monochrome android:drawable="@drawable/ic_launcher_foreground"/> 6 + </adaptive-icon>
+6
android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
··· 1 + <?xml version="1.0" encoding="utf-8"?> 2 + <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> 3 + <background android:drawable="@color/ic_launcher_background"/> 4 + <foreground android:drawable="@drawable/ic_launcher_foreground"/> 5 + <monochrome android:drawable="@drawable/ic_launcher_foreground"/> 6 + </adaptive-icon>
android/app/src/main/res/mipmap-hdpi/ic_launcher.png

This is a binary file and will not be displayed.

android/app/src/main/res/mipmap-hdpi/ic_launcher.webp

This is a binary file and will not be displayed.

android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp

This is a binary file and will not be displayed.

android/app/src/main/res/mipmap-mdpi/ic_launcher.png

This is a binary file and will not be displayed.

android/app/src/main/res/mipmap-mdpi/ic_launcher.webp

This is a binary file and will not be displayed.

android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp

This is a binary file and will not be displayed.

android/app/src/main/res/mipmap-xhdpi/ic_launcher.png

This is a binary file and will not be displayed.

android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp

This is a binary file and will not be displayed.

android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp

This is a binary file and will not be displayed.

android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png

This is a binary file and will not be displayed.

android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp

This is a binary file and will not be displayed.

android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp

This is a binary file and will not be displayed.

android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png

This is a binary file and will not be displayed.

android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp

This is a binary file and will not be displayed.

android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp

This is a binary file and will not be displayed.

android/app/src/main/res/values/colors.xml android/app/src/main/res/values/ic_launcher_background.xml
+1 -1
android/gradle/wrapper/gradle-wrapper.properties
··· 2 2 distributionPath=wrapper/dists 3 3 zipStoreBase=GRADLE_USER_HOME 4 4 zipStorePath=wrapper/dists 5 - distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip 5 + distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-all.zip
+1 -1
android/settings.gradle.kts
··· 18 18 19 19 plugins { 20 20 id("dev.flutter.flutter-plugin-loader") version "1.0.0" 21 - id("com.android.application") version "8.9.1" apply false 21 + id("com.android.application") version "8.13.2" apply false 22 22 id("org.jetbrains.kotlin.android") version "2.2.21" apply false 23 23 id("org.jetbrains.kotlin.plugin.compose") version "2.2.21" apply false 24 24 id("com.google.devtools.ksp") version "2.2.21-2.0.4" apply false
+114 -55
lib/src/features/feed/ui/widgets/images/image_carousel.dart
··· 1 1 import 'package:cached_network_image/cached_network_image.dart'; 2 - import 'package:carousel_slider/carousel_slider.dart'; 3 2 import 'package:fluentui_system_icons/fluentui_system_icons.dart'; 4 3 import 'package:flutter/material.dart'; 5 4 import 'package:flutter_riverpod/flutter_riverpod.dart'; ··· 15 14 } 16 15 17 16 class _ImageCarouselState extends ConsumerState<ImageCarousel> { 18 - late CarouselSliderController carouselController; 17 + late PageController _pageController; 18 + late List<ImageProvider> _imageProviders; 19 + late List<Widget> _cachedPages; 19 20 int currentIndex = 0; 21 + bool _imagesPreloaded = false; 20 22 21 23 @override 22 24 void initState() { 23 25 super.initState(); 24 - carouselController = CarouselSliderController(); 26 + _pageController = PageController(); 27 + // Create image providers for all images upfront 28 + _imageProviders = widget.imageUrls 29 + .map(CachedNetworkImageProvider.new) 30 + .toList(); 31 + _cachedPages = []; 32 + } 33 + 34 + @override 35 + void didChangeDependencies() { 36 + super.didChangeDependencies(); 37 + // Preload images and build cached pages once we have context 38 + if (!_imagesPreloaded) { 39 + _imagesPreloaded = true; 40 + _preloadAllImages(); 41 + _buildCachedPages(); 42 + } 43 + } 44 + 45 + @override 46 + void dispose() { 47 + _pageController.dispose(); 48 + super.dispose(); 49 + } 50 + 51 + Future<void> _preloadAllImages() async { 52 + // Preload all images in parallel 53 + await Future.wait( 54 + _imageProviders.map((provider) => precacheImage(provider, context)), 55 + ); 56 + // Rebuild to show loaded images 57 + if (mounted) { 58 + setState(_buildCachedPages); 59 + } 60 + } 61 + 62 + void _buildCachedPages() { 63 + _cachedPages = List.generate( 64 + widget.imageUrls.length, 65 + (index) => _KeepAlivePage( 66 + child: Stack( 67 + children: [ 68 + _buildImage(index), 69 + if (widget.alts != null && 70 + index < widget.alts!.length && 71 + widget.alts![index] != '') 72 + Positioned( 73 + bottom: 0, 74 + left: 0, 75 + right: 0, 76 + child: Text(widget.alts![index]), 77 + ), 78 + ], 79 + ), 80 + ), 81 + ); 82 + } 83 + 84 + Widget _buildImage(int index) { 85 + return DecoratedBox( 86 + decoration: const BoxDecoration(color: AppColors.black), 87 + child: Image( 88 + image: _imageProviders[index], 89 + fit: BoxFit.contain, 90 + height: double.infinity, 91 + width: double.infinity, 92 + gaplessPlayback: true, 93 + frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { 94 + if (wasSynchronouslyLoaded || frame != null) { 95 + return child; 96 + } 97 + return const Center(child: CircularProgressIndicator()); 98 + }, 99 + errorBuilder: (context, error, stackTrace) => const Center( 100 + child: Icon(FluentIcons.error_circle_24_regular), 101 + ), 102 + ), 103 + ); 25 104 } 26 105 27 106 Widget _buildSingleImage() { 28 107 return Stack( 29 108 children: [ 30 - DecoratedBox( 31 - decoration: const BoxDecoration(color: AppColors.black), 32 - child: CachedNetworkImage( 33 - imageUrl: widget.imageUrls[0], 34 - fit: BoxFit.contain, 35 - height: MediaQuery.of(context).size.height, 36 - placeholder: (context, url) => 37 - const Center(child: CircularProgressIndicator()), 38 - errorWidget: (context, url, error) => 39 - const Center(child: Icon(FluentIcons.error_circle_24_regular)), 40 - ), 41 - ), 109 + _buildImage(0), 42 110 if (widget.alts != null && 43 111 widget.alts!.isNotEmpty && 44 112 widget.alts![0] != '') ··· 62 130 return _buildSingleImage(); 63 131 } 64 132 65 - // Multiple images: use carousel with dots 133 + // Multiple images: use PageView with keep-alive pages 66 134 return Stack( 67 135 children: [ 68 - CarouselSlider.builder( 69 - itemCount: widget.imageUrls.length, 70 - carouselController: carouselController, 71 - itemBuilder: (context, index, realIndex) { 72 - return Stack( 73 - children: [ 74 - DecoratedBox( 75 - decoration: const BoxDecoration(color: AppColors.black), 76 - child: CachedNetworkImage( 77 - imageUrl: widget.imageUrls[realIndex], 78 - fit: BoxFit.contain, 79 - height: MediaQuery.of(context).size.height, 80 - placeholder: (context, url) => 81 - const Center(child: CircularProgressIndicator()), 82 - errorWidget: (context, url, error) => const Center( 83 - child: Icon(FluentIcons.error_circle_24_regular), 84 - ), 85 - ), 86 - ), 87 - if (widget.alts != null && widget.alts![realIndex] != '') 88 - Positioned( 89 - bottom: 0, 90 - left: 0, 91 - right: 0, 92 - child: Text(widget.alts![realIndex]), 93 - ), 94 - ], 95 - ); 136 + PageView.builder( 137 + controller: _pageController, 138 + itemCount: _cachedPages.length, 139 + allowImplicitScrolling: true, 140 + itemBuilder: (context, index) => _cachedPages[index], 141 + onPageChanged: (index) { 142 + setState(() { 143 + currentIndex = index; 144 + }); 96 145 }, 97 - options: CarouselOptions( 98 - aspectRatio: 0.5, 99 - height: MediaQuery.of(context).size.height, 100 - viewportFraction: 1, 101 - enableInfiniteScroll: false, 102 - onPageChanged: (index, reason) { 103 - setState(() { 104 - currentIndex = index; 105 - }); 106 - }, 107 - ), 108 146 ), 109 147 Align( 110 148 alignment: Alignment.bottomCenter, ··· 135 173 ); 136 174 } 137 175 } 176 + 177 + /// Wrapper widget that keeps its child alive in PageView 178 + class _KeepAlivePage extends StatefulWidget { 179 + const _KeepAlivePage({required this.child}); 180 + final Widget child; 181 + 182 + @override 183 + State<_KeepAlivePage> createState() => _KeepAlivePageState(); 184 + } 185 + 186 + class _KeepAlivePageState extends State<_KeepAlivePage> 187 + with AutomaticKeepAliveClientMixin { 188 + @override 189 + bool get wantKeepAlive => true; 190 + 191 + @override 192 + Widget build(BuildContext context) { 193 + super.build(context); 194 + return widget.child; 195 + } 196 + }