iOS client for Grain grain.social
ios photography atproto
7
fork

Configure Feed

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

feat: Instagram-style profile layout, multi-photo grid badge, story timestamp fix

- Redesign profile header: avatar left with stats right, left-aligned
name/handle/bio below
- Remove grid/list toggle, grid-only view
- Add stacked squares icon on grid tiles with multiple photos
- Fix story timestamp parsing by adding fractional seconds support
- Add explicit device install step to justfile

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+53 -86
+49 -86
Grain/Views/Profile/ProfileView.swift
··· 42 42 private var profileContent: some View { 43 43 ScrollView { 44 44 if let profile = viewModel.profile { 45 - VStack(spacing: 16) { 46 - // Avatar + name with glass header 47 - VStack(spacing: 8) { 45 + VStack(spacing: 12) { 46 + // Avatar + stats row 47 + HStack(alignment: .center, spacing: 16) { 48 48 StoryRingView(hasStory: !viewModel.stories.isEmpty, size: 80) { 49 49 AvatarView(url: profile.avatar, size: 80) 50 50 .liquidGlassCircle() ··· 57 57 } 58 58 } 59 59 60 + HStack(spacing: 0) { 61 + StatView(count: profile.galleryCount ?? 0, label: "Galleries") 62 + .frame(maxWidth: .infinity) 63 + NavigationLink { 64 + FollowListView(client: client, did: did, mode: .followers) 65 + } label: { 66 + StatView(count: profile.followersCount ?? 0, label: "Followers") 67 + } 68 + .buttonStyle(.plain) 69 + .frame(maxWidth: .infinity) 70 + NavigationLink { 71 + FollowListView(client: client, did: did, mode: .following) 72 + } label: { 73 + StatView(count: profile.followsCount ?? 0, label: "Following") 74 + } 75 + .buttonStyle(.plain) 76 + .frame(maxWidth: .infinity) 77 + } 78 + } 79 + .padding(.horizontal) 80 + .padding(.top, 8) 81 + 82 + // Name + handle + bio 83 + VStack(alignment: .leading, spacing: 4) { 60 84 Text(profile.displayName ?? profile.handle) 61 - .font(.title2.bold()) 85 + .font(.subheadline.bold()) 62 86 Text("@\(profile.handle)") 63 87 .font(.subheadline) 64 88 .foregroundStyle(.secondary) 65 - } 66 - .padding(.vertical) 67 89 68 - // Stats with glass pills 69 - HStack(spacing: 24) { 70 - StatView(count: profile.galleryCount ?? 0, label: "Galleries") 71 - NavigationLink { 72 - FollowListView(client: client, did: did, mode: .followers) 73 - } label: { 74 - StatView(count: profile.followersCount ?? 0, label: "Followers") 75 - } 76 - .buttonStyle(.plain) 77 - NavigationLink { 78 - FollowListView(client: client, did: did, mode: .following) 79 - } label: { 80 - StatView(count: profile.followsCount ?? 0, label: "Following") 90 + if let description = profile.description, !description.isEmpty { 91 + RichTextView( 92 + text: description, 93 + font: .subheadline, 94 + onMentionTap: { did in selectedProfileDid = did }, 95 + onHashtagTap: { tag in selectedHashtag = tag } 96 + ) 97 + .padding(.top, 2) 81 98 } 82 - .buttonStyle(.plain) 83 99 } 84 - .padding(.horizontal, 20) 85 - .padding(.vertical, 12) 86 - .liquidGlass() 100 + .frame(maxWidth: .infinity, alignment: .leading) 101 + .padding(.horizontal) 87 102 88 103 // Follow button 89 104 if did != auth.userDID { ··· 91 106 Task { await viewModel.toggleFollow(auth: auth.authContext()) } 92 107 } label: { 93 108 Text(profile.viewer?.following != nil ? "Following" : "Follow") 109 + .font(.subheadline.weight(.semibold)) 94 110 .frame(maxWidth: .infinity) 95 111 } 96 112 .buttonStyle(.borderedProminent) ··· 98 114 .padding(.horizontal) 99 115 } 100 116 101 - if let description = profile.description, !description.isEmpty { 102 - RichTextView( 103 - text: description, 104 - font: .body, 105 - onMentionTap: { did in selectedProfileDid = did }, 106 - onHashtagTap: { tag in selectedHashtag = tag } 107 - ) 108 - .padding(.horizontal) 109 - } 110 - 111 - // View mode toggle 112 - GlassEffectContainer(spacing: 8) { 113 - HStack(spacing: 8) { 114 - Button { 115 - withAnimation { viewMode = .grid } 116 - } label: { 117 - Image(systemName: "square.grid.2x2") 118 - .font(.system(size: 16, weight: .medium)) 119 - .foregroundStyle(viewMode == .grid ? .primary : .secondary) 120 - .frame(width: 44, height: 36) 121 - .glassEffect( 122 - viewMode == .grid ? .regular.interactive() : .clear.interactive(), 123 - in: .capsule 124 - ) 125 - .glassEffectID("viewToggle", in: viewModeNS) 126 - } 127 - Button { 128 - withAnimation { viewMode = .list } 129 - } label: { 130 - Image(systemName: "list.bullet") 131 - .font(.system(size: 16, weight: .medium)) 132 - .foregroundStyle(viewMode == .list ? .primary : .secondary) 133 - .frame(width: 44, height: 36) 134 - .glassEffect( 135 - viewMode == .list ? .regular.interactive() : .clear.interactive(), 136 - in: .capsule 137 - ) 138 - .glassEffectID("viewToggle", in: viewModeNS) 139 - } 140 - } 141 - .buttonStyle(.plain) 142 - } 143 - 144 117 // Galleries 145 - if viewMode == .grid { 146 118 LazyVGrid(columns: [ 147 119 GridItem(.flexible(), spacing: 2), 148 120 GridItem(.flexible(), spacing: 2), ··· 168 140 } 169 141 } 170 142 .clipped() 143 + .overlay(alignment: .topTrailing) { 144 + if (gallery.items?.count ?? 0) > 1 { 145 + Image(systemName: "square.on.square.fill") 146 + .font(.system(size: 14)) 147 + .rotationEffect(.degrees(180)) 148 + .foregroundStyle(.white) 149 + .shadow(color: .black.opacity(0.5), radius: 2, x: 0, y: 1) 150 + .padding(6) 151 + } 152 + } 171 153 } 172 154 .buttonStyle(.plain) 173 155 .onAppear { ··· 177 159 } 178 160 } 179 161 } 180 - } else { 181 - LazyVStack(spacing: 0) { 182 - ForEach($viewModel.galleries) { $gallery in 183 - GalleryCardView( 184 - gallery: $gallery, 185 - client: client, 186 - onNavigate: { selectedGalleryUri = gallery.uri }, 187 - onProfileTap: { did in selectedProfileDid = did }, 188 - onHashtagTap: { tag in selectedHashtag = tag }, 189 - onStoryTap: { author in cardStoryAuthor = author } 190 - ) 191 - .onAppear { 192 - if gallery.id == viewModel.galleries.last?.id { 193 - Task { await viewModel.loadMoreGalleries(did: did, auth: auth.authContext()) } 194 - } 195 - } 196 - } 197 - } 198 - } 199 162 } 200 163 } else if viewModel.isLoading { 201 164 ProgressView()
+1
Grain/Views/Stories/StoryViewer.swift
··· 318 318 319 319 private func relativeTime(_ dateString: String) -> String { 320 320 let formatter = ISO8601DateFormatter() 321 + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] 321 322 guard let date = formatter.date(from: dateString) else { return "" } 322 323 let interval = Date().timeIntervalSince(date) 323 324 if interval < 60 { return "now" }
+3
justfile
··· 22 22 set -euo pipefail 23 23 echo "Building for device {{device_id}}..." 24 24 xcodebuild build -scheme Grain -destination 'platform=iOS,id={{device_id}}' CODE_SIGN_STYLE=Automatic -allowProvisioningUpdates -quiet 25 + APP_PATH=$(find ~/Library/Developer/Xcode/DerivedData/Grain-*/Build/Products/Debug-iphoneos -name "Grain.app" -type d | head -1) 26 + echo "Installing $APP_PATH..." 27 + xcrun devicectl device install app --device {{device_id}} "$APP_PATH" 25 28 echo "Installed to device {{device_id}}!" 26 29 27 30 # Bump build number, regenerate project, archive, and upload to App Store Connect