+8
-2
lib/main.dart
+8
-2
lib/main.dart
···
10
import 'package:grain/screens/login_page.dart';
11
12
import 'providers/profile_provider.dart';
13
14
class AppConfig {
15
static late final String apiUrl;
···
98
Widget home;
99
if (_loading) {
100
home = Scaffold(
101
-
body: Center(
102
-
child: CircularProgressIndicator(strokeWidth: 2, color: AppTheme.primaryColor),
103
),
104
);
105
} else {
···
10
import 'package:grain/screens/login_page.dart';
11
12
import 'providers/profile_provider.dart';
13
+
import 'widgets/skeleton_timeline.dart';
14
15
class AppConfig {
16
static late final String apiUrl;
···
99
Widget home;
100
if (_loading) {
101
home = Scaffold(
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
+
],
109
),
110
);
111
} else {
+2
-17
lib/screens/home_page.dart
+2
-17
lib/screens/home_page.dart
···
6
import 'package:grain/screens/create_gallery_page.dart';
7
import 'package:grain/widgets/app_drawer.dart';
8
import 'package:grain/widgets/bottom_nav_bar.dart';
9
import 'package:grain/widgets/timeline_item.dart';
10
11
import '../providers/gallery_cache_provider.dart';
···
113
child: CustomScrollView(
114
key: const PageStorageKey('timeline'),
115
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
-
),
126
if (uris.isEmpty && !loading)
127
SliverFillRemaining(
128
hasScrollBody: false,
···
146
Widget build(BuildContext context) {
147
final theme = Theme.of(context);
148
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
// Home page: show default timeline only
157
if (!showProfile && !showNotifications && !showExplore) {
158
return Scaffold(
···
6
import 'package:grain/screens/create_gallery_page.dart';
7
import 'package:grain/widgets/app_drawer.dart';
8
import 'package:grain/widgets/bottom_nav_bar.dart';
9
+
import 'package:grain/widgets/skeleton_timeline.dart';
10
import 'package:grain/widgets/timeline_item.dart';
11
12
import '../providers/gallery_cache_provider.dart';
···
114
child: CustomScrollView(
115
key: const PageStorageKey('timeline'),
116
slivers: [
117
+
if (uris.isEmpty && loading) const SkeletonTimeline(useSliver: true),
118
if (uris.isEmpty && !loading)
119
SliverFillRemaining(
120
hasScrollBody: false,
···
138
Widget build(BuildContext context) {
139
final theme = Theme.of(context);
140
final avatarUrl = apiService.currentUser?.avatar;
141
// Home page: show default timeline only
142
if (!showProfile && !showNotifications && !showExplore) {
143
return Scaffold(
+109
lib/widgets/skeleton_timeline.dart
+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
+9
-9
lib/widgets/timeline_item.dart
···
22
Widget build(BuildContext context, WidgetRef ref) {
23
final gallery = ref.watch(galleryCacheProvider)[galleryUri];
24
if (gallery == null) {
25
-
return const SizedBox.shrink(); // or a loading/placeholder widget
26
}
27
final actor = gallery.creator;
28
final createdAt = gallery.createdAt;
···
39
onTap:
40
onProfileTap ??
41
() {
42
-
if (actor != null) {
43
Navigator.of(context).push(
44
MaterialPageRoute(
45
-
builder: (context) => ProfilePage(did: actor.did, showAppBar: true),
46
),
47
);
48
}
···
50
child: CircleAvatar(
51
radius: 18,
52
backgroundColor: theme.scaffoldBackgroundColor,
53
-
child: (actor != null && (actor.avatar?.isNotEmpty ?? false))
54
? ClipOval(
55
child: AppImage(
56
-
url: actor.avatar,
57
width: 36,
58
height: 36,
59
fit: BoxFit.cover,
···
76
child: Text.rich(
77
TextSpan(
78
children: [
79
-
if (actor != null && (actor.displayName?.isNotEmpty ?? false))
80
TextSpan(
81
-
text: actor.displayName,
82
style: theme.textTheme.titleMedium?.copyWith(
83
fontWeight: FontWeight.w600,
84
fontSize: 16,
···
144
padding: const EdgeInsets.only(top: 4, left: 8, right: 8),
145
child: FacetedText(
146
text: gallery.description ?? '',
147
-
facets: gallery.facets,
148
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
149
linkStyle: theme.textTheme.bodySmall?.copyWith(
150
color: theme.colorScheme.primary,
···
168
child: GalleryActionButtons(
169
gallery: gallery,
170
parentContext: context,
171
-
currentUserDid: gallery.creator?.did, // or apiService.currentUser?.did if available
172
isLoggedIn: isLoggedIn,
173
),
174
),
···
22
Widget build(BuildContext context, WidgetRef ref) {
23
final gallery = ref.watch(galleryCacheProvider)[galleryUri];
24
if (gallery == null) {
25
+
return const SizedBox.shrink();
26
}
27
final actor = gallery.creator;
28
final createdAt = gallery.createdAt;
···
39
onTap:
40
onProfileTap ??
41
() {
42
+
if (actor?.did != null) {
43
Navigator.of(context).push(
44
MaterialPageRoute(
45
+
builder: (context) => ProfilePage(did: actor!.did, showAppBar: true),
46
),
47
);
48
}
···
50
child: CircleAvatar(
51
radius: 18,
52
backgroundColor: theme.scaffoldBackgroundColor,
53
+
child: (actor?.avatar?.isNotEmpty ?? false)
54
? ClipOval(
55
child: AppImage(
56
+
url: actor!.avatar ?? '',
57
width: 36,
58
height: 36,
59
fit: BoxFit.cover,
···
76
child: Text.rich(
77
TextSpan(
78
children: [
79
+
if (actor?.displayName?.isNotEmpty ?? false)
80
TextSpan(
81
+
text: actor!.displayName ?? '',
82
style: theme.textTheme.titleMedium?.copyWith(
83
fontWeight: FontWeight.w600,
84
fontSize: 16,
···
144
padding: const EdgeInsets.only(top: 4, left: 8, right: 8),
145
child: FacetedText(
146
text: gallery.description ?? '',
147
+
facets: gallery.facets ?? [],
148
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
149
linkStyle: theme.textTheme.bodySmall?.copyWith(
150
color: theme.colorScheme.primary,
···
168
child: GalleryActionButtons(
169
gallery: gallery,
170
parentContext: context,
171
+
currentUserDid: gallery.creator?.did ?? '',
172
isLoggedIn: isLoggedIn,
173
),
174
),