feat: Add SkeletonTimeline widget for loading states and update main and home page to use it

Changed files
+128 -28
lib
+8 -2
lib/main.dart
··· 10 10 import 'package:grain/screens/login_page.dart'; 11 11 12 12 import 'providers/profile_provider.dart'; 13 + import 'widgets/skeleton_timeline.dart'; 13 14 14 15 class AppConfig { 15 16 static late final String apiUrl; ··· 98 99 Widget home; 99 100 if (_loading) { 100 101 home = Scaffold( 101 - body: Center( 102 - child: CircularProgressIndicator(strokeWidth: 2, color: AppTheme.primaryColor), 102 + appBar: AppBar(title: const Text('Grain')), 103 + body: Column( 104 + children: [ 105 + Expanded( 106 + child: SkeletonTimeline(padding: EdgeInsets.symmetric(vertical: 24, horizontal: 8)), 107 + ), 108 + ], 103 109 ), 104 110 ); 105 111 } else {
+2 -17
lib/screens/home_page.dart
··· 6 6 import 'package:grain/screens/create_gallery_page.dart'; 7 7 import 'package:grain/widgets/app_drawer.dart'; 8 8 import 'package:grain/widgets/bottom_nav_bar.dart'; 9 + import 'package:grain/widgets/skeleton_timeline.dart'; 9 10 import 'package:grain/widgets/timeline_item.dart'; 10 11 11 12 import '../providers/gallery_cache_provider.dart'; ··· 113 114 child: CustomScrollView( 114 115 key: const PageStorageKey('timeline'), 115 116 slivers: [ 116 - if (uris.isEmpty && loading) 117 - SliverFillRemaining( 118 - hasScrollBody: false, 119 - child: Center( 120 - child: CircularProgressIndicator( 121 - strokeWidth: 2, 122 - color: Theme.of(context).colorScheme.primary, 123 - ), 124 - ), 125 - ), 117 + if (uris.isEmpty && loading) const SkeletonTimeline(useSliver: true), 126 118 if (uris.isEmpty && !loading) 127 119 SliverFillRemaining( 128 120 hasScrollBody: false, ··· 146 138 Widget build(BuildContext context) { 147 139 final theme = Theme.of(context); 148 140 final avatarUrl = apiService.currentUser?.avatar; 149 - if (apiService.currentUser == null) { 150 - return Scaffold( 151 - body: Center( 152 - child: CircularProgressIndicator(strokeWidth: 2, color: theme.colorScheme.primary), 153 - ), 154 - ); 155 - } 156 141 // Home page: show default timeline only 157 142 if (!showProfile && !showNotifications && !showExplore) { 158 143 return Scaffold(
+109
lib/widgets/skeleton_timeline.dart
··· 1 + import 'package:flutter/material.dart'; 2 + 3 + class SkeletonTimeline extends StatelessWidget { 4 + final int itemCount; 5 + final bool useSliver; 6 + final EdgeInsetsGeometry padding; 7 + const SkeletonTimeline({ 8 + super.key, 9 + this.itemCount = 6, 10 + this.useSliver = false, 11 + this.padding = const EdgeInsets.symmetric(vertical: 24, horizontal: 8), 12 + }); 13 + 14 + @override 15 + Widget build(BuildContext context) { 16 + if (useSliver) { 17 + return SliverPadding( 18 + padding: padding, 19 + sliver: SliverList( 20 + delegate: SliverChildBuilderDelegate( 21 + (context, index) => _buildSkeletonItem(context, index), 22 + childCount: itemCount, 23 + ), 24 + ), 25 + ); 26 + } else { 27 + return ListView.builder( 28 + itemCount: itemCount, 29 + padding: padding, 30 + itemBuilder: (context, index) => _buildSkeletonItem(context, index), 31 + ); 32 + } 33 + } 34 + 35 + Widget _buildSkeletonItem(BuildContext context, int index) { 36 + final theme = Theme.of(context); 37 + final Color skeletonColor = theme.colorScheme.surfaceContainerHighest.withAlpha(128); 38 + final double fade = 1.0 - (index / itemCount) * 0.3; 39 + final Color fadedColor = skeletonColor.withOpacity(fade); 40 + 41 + return Padding( 42 + padding: const EdgeInsets.only(bottom: 24), 43 + child: Column( 44 + crossAxisAlignment: CrossAxisAlignment.start, 45 + children: [ 46 + Row( 47 + children: [ 48 + Container( 49 + width: 36, 50 + height: 36, 51 + decoration: BoxDecoration(color: fadedColor, shape: BoxShape.circle), 52 + ), 53 + const SizedBox(width: 10), 54 + Expanded( 55 + child: Column( 56 + crossAxisAlignment: CrossAxisAlignment.start, 57 + children: [ 58 + Container(height: 14, width: 120, color: fadedColor), 59 + const SizedBox(height: 6), 60 + Container(height: 12, width: 80, color: fadedColor), 61 + ], 62 + ), 63 + ), 64 + Container(height: 12, width: 40, color: fadedColor), 65 + ], 66 + ), 67 + const SizedBox(height: 12), 68 + Container( 69 + height: 180, 70 + width: double.infinity, 71 + color: fadedColor, 72 + margin: const EdgeInsets.symmetric(horizontal: 2), 73 + ), 74 + const SizedBox(height: 12), 75 + Container( 76 + height: 16, 77 + width: 160, 78 + color: fadedColor, 79 + margin: const EdgeInsets.only(left: 2), 80 + ), 81 + const SizedBox(height: 8), 82 + Container( 83 + height: 12, 84 + width: double.infinity, 85 + color: fadedColor, 86 + margin: const EdgeInsets.only(left: 2), 87 + ), 88 + const SizedBox(height: 16), 89 + Row( 90 + children: List.generate( 91 + 3, 92 + (i) => Padding( 93 + padding: const EdgeInsets.only(right: 12), 94 + child: Container( 95 + width: 32, 96 + height: 32, 97 + decoration: BoxDecoration( 98 + color: fadedColor, 99 + borderRadius: BorderRadius.circular(8), 100 + ), 101 + ), 102 + ), 103 + ), 104 + ), 105 + ], 106 + ), 107 + ); 108 + } 109 + }
+9 -9
lib/widgets/timeline_item.dart
··· 22 22 Widget build(BuildContext context, WidgetRef ref) { 23 23 final gallery = ref.watch(galleryCacheProvider)[galleryUri]; 24 24 if (gallery == null) { 25 - return const SizedBox.shrink(); // or a loading/placeholder widget 25 + return const SizedBox.shrink(); 26 26 } 27 27 final actor = gallery.creator; 28 28 final createdAt = gallery.createdAt; ··· 39 39 onTap: 40 40 onProfileTap ?? 41 41 () { 42 - if (actor != null) { 42 + if (actor?.did != null) { 43 43 Navigator.of(context).push( 44 44 MaterialPageRoute( 45 - builder: (context) => ProfilePage(did: actor.did, showAppBar: true), 45 + builder: (context) => ProfilePage(did: actor!.did, showAppBar: true), 46 46 ), 47 47 ); 48 48 } ··· 50 50 child: CircleAvatar( 51 51 radius: 18, 52 52 backgroundColor: theme.scaffoldBackgroundColor, 53 - child: (actor != null && (actor.avatar?.isNotEmpty ?? false)) 53 + child: (actor?.avatar?.isNotEmpty ?? false) 54 54 ? ClipOval( 55 55 child: AppImage( 56 - url: actor.avatar, 56 + url: actor!.avatar ?? '', 57 57 width: 36, 58 58 height: 36, 59 59 fit: BoxFit.cover, ··· 76 76 child: Text.rich( 77 77 TextSpan( 78 78 children: [ 79 - if (actor != null && (actor.displayName?.isNotEmpty ?? false)) 79 + if (actor?.displayName?.isNotEmpty ?? false) 80 80 TextSpan( 81 - text: actor.displayName, 81 + text: actor!.displayName ?? '', 82 82 style: theme.textTheme.titleMedium?.copyWith( 83 83 fontWeight: FontWeight.w600, 84 84 fontSize: 16, ··· 144 144 padding: const EdgeInsets.only(top: 4, left: 8, right: 8), 145 145 child: FacetedText( 146 146 text: gallery.description ?? '', 147 - facets: gallery.facets, 147 + facets: gallery.facets ?? [], 148 148 style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), 149 149 linkStyle: theme.textTheme.bodySmall?.copyWith( 150 150 color: theme.colorScheme.primary, ··· 168 168 child: GalleryActionButtons( 169 169 gallery: gallery, 170 170 parentContext: context, 171 - currentUserDid: gallery.creator?.did, // or apiService.currentUser?.did if available 171 + currentUserDid: gallery.creator?.did ?? '', 172 172 isLoggedIn: isLoggedIn, 173 173 ), 174 174 ),